diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 750b75491..f3e3c2499 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -32,6 +32,187 @@ jobs: echo "Required OpenCode workflow materialized without checking out or executing pull-request content." + - name: Resolve immutable central policy source + id: trusted_source + env: + JOB_CONTEXT_JSON: ${{ toJSON(job) }} + WORKFLOW_SHA: ${{ github.workflow_sha }} + WORKFLOW_REF: ${{ github.workflow_ref }} + run: | + set -euo pipefail + python3 <<'PY' >>"$GITHUB_OUTPUT" + import json + import os + import re + import sys + + try: + job_context = json.loads(os.environ.get("JOB_CONTEXT_JSON") or "{}") + except json.JSONDecodeError as exc: + print(f"::error::Could not parse GitHub workflow context JSON: {exc}", file=sys.stderr) + raise SystemExit(1) + + expected_repository = "ContextualWisdomLab/.github" + expected_file = ".github/workflows/opencode-review.yml" + workflow_sha = str( + job_context.get("workflow_sha") or os.environ.get("WORKFLOW_SHA") or "" + ).strip() + workflow_ref = str( + job_context.get("workflow_ref") or os.environ.get("WORKFLOW_REF") or "" + ).strip() + workflow_ref_head, separator, _ = workflow_ref.partition("@") + if not separator: + print("::error::Required workflow ref is missing its immutable ref separator.", file=sys.stderr) + raise SystemExit(1) + ref_parts = workflow_ref_head.split("/", 2) + if len(ref_parts) < 2 or not ref_parts[0] or not ref_parts[1]: + print("::error::Required workflow ref does not identify a repository.", file=sys.stderr) + raise SystemExit(1) + workflow_repository = "/".join(ref_parts[:2]) + workflow_file_path = str(job_context.get("workflow_file_path") or "").strip() + + if not workflow_file_path: + prefix = f"{expected_repository}/{expected_file}@" + if workflow_ref.startswith(prefix): + workflow_file_path = expected_file + + if workflow_repository != expected_repository: + print( + f"::error::Required workflow repository resolved to {workflow_repository}, expected {expected_repository}.", + file=sys.stderr, + ) + raise SystemExit(1) + if not re.fullmatch(r"[0-9a-fA-F]{40}", workflow_sha): + print("::error::Required workflow SHA is missing or malformed.", file=sys.stderr) + raise SystemExit(1) + if workflow_file_path != expected_file: + print("::error::Required workflow file path is missing or unexpected.", file=sys.stderr) + raise SystemExit(1) + expected_ref_prefix = f"{expected_repository}/{expected_file}@" + if not workflow_ref.startswith(expected_ref_prefix): + print("::error::Required workflow ref is missing or inconsistent.", file=sys.stderr) + raise SystemExit(1) + + print(f"repository={workflow_repository}") + print(f"sha={workflow_sha}") + print(f"workflow_file_path={workflow_file_path}") + PY + + - name: Materialize trusted central policy source + env: + GH_TOKEN: ${{ github.token }} + TRUSTED_SOURCE_REF: ${{ steps.trusted_source.outputs.sha }} + run: | + set -euo pipefail + if [[ ! "$TRUSTED_SOURCE_REF" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "::error::Trusted central policy source ref must resolve to the immutable workflow commit SHA before archive materialization." + exit 1 + fi + trusted_archive="${RUNNER_TEMP}/trusted-opencode-policy-source.tar.gz" + trusted_source_dir="${GITHUB_WORKSPACE}/.cwl-required-source" + api_url="${GITHUB_API_URL:-https://api.github.com}" + mkdir -p "$trusted_source_dir" + curl -fsSL \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + -o "$trusted_archive" \ + "${api_url}/repos/ContextualWisdomLab/.github/tarball/${TRUSTED_SOURCE_REF}" + python3 - "$trusted_archive" "$trusted_source_dir" <<'PY' + import shutil + import sys + import tarfile + from pathlib import Path, PurePosixPath + + archive_path = Path(sys.argv[1]) + root = Path(sys.argv[2]) + try: + if root.is_symlink(): + raise ValueError("trusted source directory must not be a symlink") + if root.exists(): + shutil.rmtree(root) + root.mkdir(parents=True) + with tarfile.open(archive_path, "r:gz") as archive: + members = archive.getmembers() + top_levels: set[str] = set() + targets: set[str] = set() + directories: list[tuple[tarfile.TarInfo, tuple[str, ...]]] = [] + files: list[tuple[tarfile.TarInfo, tuple[str, ...]]] = [] + for member in members: + name = member.name + if not name or name.startswith("/") or "\x00" in name or "\\" in name: + raise ValueError(f"unsafe archive member path: {name!r}") + parts = PurePosixPath(name).parts + if not parts or parts[0] in {".", ".."}: + raise ValueError(f"unsafe archive member path: {name!r}") + top_levels.add(parts[0]) + relative_parts = parts[1:] + if not relative_parts: + if not member.isdir(): + raise ValueError("archive root must be a directory") + continue + if any(part in {"", ".", ".."} for part in relative_parts): + raise ValueError(f"unsafe archive member path: {name!r}") + relative_key = "/".join(relative_parts) + if relative_key in targets: + raise ValueError(f"duplicate archive member path: {relative_key}") + targets.add(relative_key) + if member.isdir(): + directories.append((member, relative_parts)) + elif member.isfile(): + files.append((member, relative_parts)) + else: + raise ValueError(f"unsupported archive member type: {name!r}") + if len(top_levels) != 1: + raise ValueError("archive must contain exactly one top-level directory") + for _member, relative_parts in sorted( + directories, key=lambda item: len(item[1]) + ): + (root / Path(*relative_parts)).mkdir(parents=True, exist_ok=True) + for member, relative_parts in files: + destination = root / Path(*relative_parts) + destination.parent.mkdir(parents=True, exist_ok=True) + source = archive.extractfile(member) + if source is None: + raise ValueError(f"archive member is not readable: {member.name!r}") + with source, destination.open("xb") as output: + shutil.copyfileobj(source, output) + except (OSError, tarfile.TarError, ValueError) as exc: + raise SystemExit(f"trusted source archive failed closed: {exc}") from exc + PY + + - name: Verify immutable central policy source + env: + EXPECTED_FILE: ${{ steps.trusted_source.outputs.workflow_file_path }} + run: | + set -euo pipefail + trusted_source_dir="$GITHUB_WORKSPACE/.cwl-required-source" + if [ ! -f "$trusted_source_dir/$EXPECTED_FILE" ] || [ -L "$trusted_source_dir/$EXPECTED_FILE" ]; then + printf '::error::Required workflow source file is missing or symlinked: %s.\n' \ + "$EXPECTED_FILE" + exit 1 + fi + if [ ! -f "$trusted_source_dir/scripts/ci/pingora_edge_policy.py" ] || [ -L "$trusted_source_dir/scripts/ci/pingora_edge_policy.py" ]; then + echo "::error::Trusted Pingora edge policy helper is missing or symlinked." + exit 1 + fi + + - name: Enforce Cloudflare Pingora edge policy + if: ${{ github.event_name == 'pull_request_target' }} + env: + GITHUB_TOKEN: ${{ github.token }} + TARGET_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.repository }} + PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number || 0 }} + PULL_REQUEST_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + EVENT_ACTION: ${{ github.event.action || 'unknown' }} + run: | + set -euo pipefail + python3 .cwl-required-source/scripts/ci/pingora_edge_policy.py \ + --repository "$TARGET_REPOSITORY" \ + --pull-request "$PULL_REQUEST_NUMBER" \ + --head-sha "$PULL_REQUEST_HEAD_SHA" \ + --event-action "$EVENT_ACTION" \ + --api-url "https://api.github.com" + coverage-source-tree: name: coverage-source-tree needs: [required-workflow-bootstrap] diff --git a/AGENTS.md b/AGENTS.md index e574852cd..0cdb050ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ directive is not trust evidence. See Conflict-scope roots fail closed when the immediate parent directory is a symbolic link. OriginWeave hourly NVIDIA NIM repair is a thin caller at minute 10. See [`docs/doctoring/originweave-hourly-review-caller.md`](docs/doctoring/originweave-hourly-review-caller.md). nonnest2 hourly NVIDIA NIM repair is a thin caller at minute 16. See [`docs/doctoring/nonnest2-hourly-review-caller.md`](docs/doctoring/nonnest2-hourly-review-caller.md). +Organization edge runtimes use Cloudflare Pingora. Do not add or preserve active Nginx containers, packages, commands, service/config files, or Kubernetes Nginx ingress annotations/classes. Read [`docs/policies/PINGORA_EDGE_POLICY.md`](docs/policies/PINGORA_EDGE_POLICY.md) and ADR-0019 before changing HTTP edge, static-serving, ingress, TLS, or proxy deployment behavior. Semgrep hosted scans bind one job-level `SEMGREP_IMAGE` digest for log evidence, manifest inspection, and `docker run`. See [`docs/doctoring/semgrep-image-digest-single-source.md`](docs/doctoring/semgrep-image-digest-single-source.md). OpenCode may repair only trusted `path:line` bindings on LLM probes that already carry an independent proof and source-line digest. See [`docs/doctoring/opencode-llm-review-publication.md`](docs/doctoring/opencode-llm-review-publication.md). @@ -23,5 +24,4 @@ as bootstrap transport in the same process that discovers models and serves; the review model is the fail-closed zero-cost pool `orchestrator/free` with ZDR-compliant routes prioritized by [`scripts/ci/zdr_policy.py`](scripts/ci/zdr_policy.py). See [`docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md`](docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md). - The materialization contract is also covered by [`docs/doctoring/exact-artifact-sbom-attestation.md`](docs/doctoring/exact-artifact-sbom-attestation.md). diff --git a/docs/adr/0019-cloudflare-pingora-edge-standard.md b/docs/adr/0019-cloudflare-pingora-edge-standard.md new file mode 100644 index 000000000..805e538b8 --- /dev/null +++ b/docs/adr/0019-cloudflare-pingora-edge-standard.md @@ -0,0 +1,70 @@ +# ADR-0019: Standardize the CWL edge runtime on Cloudflare Pingora + +- **Status:** Accepted +- **Date:** 2026-08-18 +- **Decision owners:** ContextualWisdomLab platform and product maintainers + +## Context + +CWL repositories currently carry several unrelated Nginx images, configuration +files, ingress annotations, service scripts, and host runbooks. The duplication +creates drift in headers, TLS, timeouts, WebSocket handling, non-root operation, +metrics, and security patching. It also makes every product repository an edge +runtime maintainer. + +Cloudflare Pingora supplies an Apache-2.0, Rust-based framework for HTTP/1 and +HTTP/2 proxying, TLS, gRPC/WebSocket forwarding, graceful reload, failover, and +observability. It is a framework, not a drop-in parser for Nginx configuration, +so a governed shared implementation is required. + +## Decision + +1. Pingora is the only approved CWL public HTTP reverse-proxy, load-balancer, and + static edge runtime. +2. The shared implementation pins Pingora `0.8.1`; updates use a reviewed version + bump, security advisory review, compatibility tests, and exact-current-head CI. +3. Product repositories consume versioned static/proxy artifacts and declarative + contracts. Environment deployment remains in `linux-cluster-ops`. +4. The organization required workflow rejects active Nginx runtime artifacts in + changed final files without executing pull-request code. +5. Only dedicated source fixtures and the policy scanner may contain denied Nginx + samples; executable integration and end-to-end test helpers remain candidates + for enforcement. +6. Initial migration does not use Pingora's experimental cache integration. +7. PHP workloads move to an HTTP application server or reviewed FastCGI adapter + behind Pingora before the public listener changes. + +## Consequences + +### Positive + +- One memory-safe, programmable edge framework and patch stream. +- Reusable security headers, request limits, metrics, graceful shutdown, and + connection-management behavior. +- Product repositories stop maintaining bespoke proxy configuration. +- Exact-head organization enforcement prevents regression. + +### Costs and risks + +- Nginx configuration cannot be translated mechanically; behavior must be tested. +- The organization owns Rust proxy code and its release lifecycle. +- TLS/SNI, FastCGI, caching, and advanced ingress features need explicit modules. +- A faulty shared artifact has broad blast radius, so canary, digest pinning, + rollback, and independent review are mandatory. + +## Alternatives rejected + +- **Keep Nginx with templates:** preserves configuration drift and C-runtime risk. +- **Traefik as the universal gateway:** useful off-the-shelf controller, but does + not meet the user-mandated Pingora standard and creates a second edge runtime. +- **Per-repository Pingora binaries:** duplicates security logic and fragments the + upgrade path. +- **Immediate host replacement without behavior tests:** creates unacceptable + outage and certificate risk. + +## Validation + +The policy scanner has 100% production statement and branch coverage, bounded +GitHub API evidence, path/control escaping, pagination limits, exact-head content +inspection, and fail-closed malformed-evidence tests. Product migrations require +site/proxy behavior tests and deployment-specific smoke tests before cutover. diff --git a/docs/doctoring/pingora-edge-standard.md b/docs/doctoring/pingora-edge-standard.md new file mode 100644 index 000000000..bb624a9c5 --- /dev/null +++ b/docs/doctoring/pingora-edge-standard.md @@ -0,0 +1,42 @@ +# Doctoring record: Cloudflare Pingora edge standard + +## Decision trace + +CWL standardizes public HTTP edge behavior on Cloudflare Pingora and prohibits +active Nginx runtime artifacts. Pingora is treated as a programmable framework; +shared binaries and declarative contracts prevent each product from becoming a +proxy implementation owner. + +The minimum allowed Pingora line is `0.8.x`. Versions through `0.7.0` were affected +by a critical HTTP request-smuggling flaw caused by ambiguous HTTP/1 framing; +`0.8.0` patched it. The selected `0.8.1` release additionally bounded default +HTTP/2 server limits and updated security-sensitive Rustls development +dependencies. The initial CWL implementation avoids experimental cache APIs. + +## Standards and controls + +- HTTP parsing and proxy behavior must follow the patched Pingora framing model + and RFC 9112 semantics referenced by the upstream advisory. +- The shared artifact is Apache-2.0 compatible with CWL permissive-license policy. +- Required-workflow code is bound to its immutable central SHA and never executes + pull-request content. +- Runtime evidence is bounded to one-megabyte UTF-8 regular files and a maximum of + 3,000 changed files; missing or malformed evidence fails closed. +- Exact-head product tests cover host/path routing, SPA fallback, security headers, + WebSocket/streaming, body limits, health, metrics, TLS, and graceful shutdown as + applicable. + +## APA 7th references + +Cloudflare, Inc. (2026, June 4). *Pingora 0.8.1* [Software release]. GitHub. +https://github.com/cloudflare/pingora/releases/tag/0.8.1 + +Cloudflare, Inc. (2026, March 5). *HTTP request smuggling via HTTP/1.0 and +Transfer-Encoding misparsing* (GHSA-hj7x-879w-vrp7) [Security advisory]. GitHub. +https://github.com/cloudflare/pingora/security/advisories/GHSA-hj7x-879w-vrp7 + +Cloudflare, Inc. (n.d.). *Pingora* [Computer software]. GitHub. Retrieved August +18, 2026, from https://github.com/cloudflare/pingora + +Cloudflare, Inc. (n.d.). *Pingora user guide*. GitHub. Retrieved August 18, 2026, +from https://github.com/cloudflare/pingora/tree/main/docs/user_guide diff --git a/docs/policies/PINGORA_EDGE_POLICY.md b/docs/policies/PINGORA_EDGE_POLICY.md new file mode 100644 index 000000000..4d4c0752e --- /dev/null +++ b/docs/policies/PINGORA_EDGE_POLICY.md @@ -0,0 +1,64 @@ +# Cloudflare Pingora Edge Runtime Policy + +## Binding rule + +ContextualWisdomLab production and test edge runtimes use **Cloudflare Pingora**. +Active Nginx containers, packages, commands, configuration files, Kubernetes +Nginx ingress annotations/classes, and host-service units are prohibited. + +This is a runtime boundary, not a vocabulary ban. Documentation, license notices, +dedicated source fixtures under `tests/fixtures/`, the scanner source itself, and +migration histories may name Nginx. Executable integration and end-to-end test +helpers remain runtime candidates. Pull requests that modify a runtime candidate +are evaluated against the final exact head file, so deleting a legacy artifact is +allowed while preserving it or introducing a new one fails closed. + +## Why this is not a search-and-replace + +Pingora is a programmable Rust framework rather than an Nginx configuration +interpreter. The organization therefore maintains reusable, versioned Pingora +static-serving and proxy artifacts and gives product repositories only declarative +route/site contracts. Product repositories do not fork proxy internals. + +## Required migration contract + +1. Inventory the current listener, host/path matching, TLS ownership, static root, + upstream protocol, WebSocket/streaming behavior, body/timeout limits, headers, + health probes, metrics, and rollback path. +2. Reproduce those behaviors with the approved Pingora artifact and a versioned + route/site manifest. +3. Add behavior-level tests before deleting the old runtime artifact. +4. Pin Pingora to an exact release at or above `0.8.0`; the shared baseline is + `0.8.1`. Do not use the experimental Pingora cache integration in the initial + migration. +5. Preserve certificate data and rollback evidence, but never keep a runnable + Nginx fallback after cutover. Rollback means redeploying the prior application + release behind Pingora, not reintroducing Nginx. +6. Treat PHP/FastCGI workloads as application-runtime migrations: place an + HTTP-capable PHP application server or a reviewed FastCGI adapter behind + Pingora before cutover. Pingora must remain the public HTTP/TLS edge. + +## Ownership + +- `.github` owns the binding policy, scanner, shared contracts, and required gate. +- `linux-cluster-ops` owns environment-specific listeners, certificates, routes, + service units, backups, rollout, and host cutover. +- Each product owns its static build or upstream application behavior and tests. +- Keyverse remains the identity authority; an edge runtime never becomes the + identity system of record. + +## Enforcement and evidence + +The organization-required `required-workflow-bootstrap` job runs trusted +base-branch scanner code at the immutable required-workflow SHA. It reads bounded +changed-file metadata and final UTF-8 content through GitHub's REST API. It does +not check out or execute pull-request content and receives only read permissions. +Malformed, truncated, binary, symlink, oversized, or unavailable evidence fails +closed. + +## Exception process + +There is no standing Nginx exception. A temporary exception requires a public ADR +with an owner, exact affected asset, buyer impact, security controls, removal date, +and an approved Pingora migration PR. The central scanner remains unchanged; the +exception is implemented by completing the migration before merge. diff --git a/scripts/ci/pingora_edge_policy.py b/scripts/ci/pingora_edge_policy.py new file mode 100644 index 000000000..706fe69fc --- /dev/null +++ b/scripts/ci/pingora_edge_policy.py @@ -0,0 +1,400 @@ +#!/usr/bin/env python3 +"""Enforce the CWL Pingora-only edge runtime policy on pull-request changes. + +The checker never executes pull-request content. It reads changed-file metadata and +bounded UTF-8 file content through the GitHub REST API, then rejects active Nginx +runtime artifacts while allowing documentation, license text, and source-level +negative test fixtures. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import re +import sys +from dataclasses import dataclass +from pathlib import PurePosixPath +from typing import Callable, Mapping, Sequence +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener + +MAX_FILE_BYTES = 1_048_576 +MAX_RESPONSE_BYTES = 16_777_216 +REPOSITORY_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +SHA_RE = re.compile(r"^[0-9a-f]{40}$") +GITHUB_API_ORIGIN = "https://api.github.com" + +DOCUMENT_SUFFIXES = frozenset({".md", ".mdx", ".rst", ".adoc", ".txt"}) +SOURCE_TEST_SUFFIXES = frozenset({".py", ".pyi", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".rs"}) +LICENSE_NAMES = frozenset({"license", "license.md", "copying", "copyrights", "notice"}) +DOCUMENTATION_DIRECTORIES = frozenset({"doc", "docs", "documentation"}) +DOCUMENTATION_ROOT_NAMES = frozenset({"readme", "changelog", "changes"}) + +RUNTIME_PATH_NAMES = frozenset({ + "dockerfile", + "containerfile", + "nginx.conf", + "nginx.service", +}) +SUDO_ARGUMENT_OPTION_RE = ( + r"(?:-(?:u|g|h|C|p|R|T)|--(?:user|group|host|close-from|prompt|chroot|command-timeout))" +) +SUDO_OPTION_RE = ( + rf"(?:{SUDO_ARGUMENT_OPTION_RE}(?:=|\s+)\S+|" + rf"(?!(?:{SUDO_ARGUMENT_OPTION_RE})(?:=|\s|$))--?\S+|--)" +) +SUDO_PREFIX_RE = rf"(?:sudo\s+(?:{SUDO_OPTION_RE}\s+)*|)" +NGINX_RUNTIME_IMAGE_RE = ( + r"(?:nginx|nginx-(?!prometheus-exporter(?:[:@\s]|$))[A-Za-z0-9._-]+)" +) + +CONTENT_RULES: tuple[tuple[str, re.Pattern[str]], ...] = ( + ( + "nginx_container_image", + re.compile( + r"(?im)^\s*(?:-\s*)?(?:FROM|image:)\s+" + r"(?:[A-Za-z0-9._-]+(?::[0-9]+)?/)*" + rf"{NGINX_RUNTIME_IMAGE_RE}" + r"(?:[:@]\S+|\s|$)" + ), + ), + ( + "nginx_ingress_controller", + re.compile( + r"(?im)(?:nginx\.ingress\.kubernetes\.io/|" + r"kubernetes\.io/ingress\.class\s*:\s*(?:[\"']nginx[\"']|nginx(?:\s|$))|" + r"ingressClassName\s*:\s*(?:[\"']nginx[\"']|nginx(?:\s|$)))" + ), + ), + ( + "nginx_runtime_command", + re.compile( + r"(?im)(?:^\s*(?:systemctl|service)\s+(?:--\S+\s+)*(?:\S+\s+)*nginx\b|" + rf"^\s*{SUDO_PREFIX_RE}nginx(?=\s|$|[;&|])|" + r"(?:CMD|ENTRYPOINT)\s*\[[^\n]*[\"']nginx[\"']|" + r"\bnginx\s+-g\s+[\"']daemon\s+off;)" + ), + ), + ( + "nginx_runtime_path", + re.compile( + r"(?i)(?:/etc/nginx(?:/|\b)|/var/(?:cache|run|log)/nginx(?:/|\b)|" + r"/usr/share/nginx(?:/|\b))" + ), + ), + ( + "nginx_package_install", + re.compile( + rf"(?im)^\s*(?:RUN\s+)?{SUDO_PREFIX_RE}(?:apk\s+add|apt(?:-get)?\s+install|" + r"dnf\s+install|yum\s+install)\b(?:[^\n#]*\\\s*\n\s*)*[^\n#]*\bnginx\b" + ), + ), +) + + +@dataclass(frozen=True) +class ChangedFile: + """A bounded subset of GitHub pull-request changed-file metadata.""" + + path: str + status: str + patch: str + patch_available: bool = True + + +@dataclass(frozen=True) +class Violation: + """A single policy violation suitable for GitHub annotation output.""" + + path: str + rule: str + line: int + excerpt: str + + +class PolicyError(RuntimeError): + """Raised when policy evidence cannot be collected or validated safely.""" + + +OpenJson = Callable[[str, str], object] + + +class NoRedirectHandler(HTTPRedirectHandler): + """Reject redirects so validated GitHub API requests keep one origin.""" + + def redirect_request(self, *_args: object, **_kwargs: object) -> None: + """Return no follow-up request for any HTTP redirect response.""" + return None + + +github_opener = build_opener(NoRedirectHandler()) + + +def _is_documentation_or_source_fixture(path: str) -> bool: + """Return whether *path* is prose, license text, or scanner source fixture.""" + + pure = PurePosixPath(path) + lower_name = pure.name.lower() + stem = pure.stem.lower() + is_known_documentation_path = pure.parts and ( + any(part.lower() in DOCUMENTATION_DIRECTORIES for part in pure.parts) + or (len(pure.parts) == 1 and stem in DOCUMENTATION_ROOT_NAMES) + ) + if lower_name in LICENSE_NAMES or ( + is_known_documentation_path and pure.suffix.lower() in DOCUMENT_SUFFIXES + ): + return True + if pure.as_posix() == "scripts/ci/pingora_edge_policy.py": + return True + lower_parts = tuple(part.lower() for part in pure.parts) + is_tests_fixture = len(lower_parts) >= 2 and lower_parts[:2] == ("tests", "fixtures") + if is_tests_fixture and pure.suffix.lower() in SOURCE_TEST_SUFFIXES | DOCUMENT_SUFFIXES: + return True + return False + + +def _runtime_path_rule(path: str) -> str | None: + """Return a path-level violation rule for active Nginx runtime artifacts.""" + + pure = PurePosixPath(path) + lower_parts = tuple(part.lower() for part in pure.parts) + lower_name = pure.name.lower() + if lower_name in RUNTIME_PATH_NAMES and "nginx" in lower_name: + return "nginx_runtime_artifact" + if "nginx" in lower_parts: + return "nginx_runtime_artifact" + if lower_name.startswith("nginx-") and pure.suffix.lower() in {".conf", ".service", ".sh", ".yaml", ".yml"}: + return "nginx_runtime_artifact" + return None + + +def _line_number(content: str, start: int) -> int: + """Translate a character offset into a one-based line number.""" + + return content.count("\n", 0, start) + 1 + + +def scan_content(path: str, content: str) -> tuple[Violation, ...]: + """Return all Pingora policy violations found in one final file version.""" + + if _is_documentation_or_source_fixture(path): + return () + violations: list[Violation] = [] + path_rule = _runtime_path_rule(path) + if path_rule is not None: + violations.append(Violation(path, path_rule, 1, "active Nginx runtime artifact path")) + for rule, pattern in CONTENT_RULES: + for match in pattern.finditer(content): + excerpt = " ".join(match.group(0).strip().split())[:160] + violations.append(Violation(path, rule, _line_number(content, match.start()), excerpt)) + return tuple(violations) + + +def _validate_github_api_url(url: str) -> None: + """Reject policy evidence URLs outside the public GitHub REST origin.""" + + parsed = urlsplit(url) + if ( + parsed.scheme != "https" + or parsed.hostname != "api.github.com" + or parsed.port is not None + or parsed.username is not None + or parsed.password is not None + or not parsed.path.startswith("/repos/") + or parsed.fragment + ): + raise PolicyError("GitHub API policy URL is outside the approved origin") + + +def _github_open_json(url: str, token: str) -> object: + """Read one bounded GitHub REST JSON document using bearer authentication.""" + + _validate_github_api_url(url) + request = Request( # noqa: S310 - URL is validated immediately above + url, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "cwl-pingora-edge-policy/1", + }, + ) + try: + with github_opener.open(request, timeout=30) as response: + payload = response.read(MAX_RESPONSE_BYTES + 1) + except (HTTPError, URLError, TimeoutError) as exc: + raise PolicyError(f"GitHub API request failed for policy evidence: {type(exc).__name__}") from exc + if len(payload) > MAX_RESPONSE_BYTES: + raise PolicyError("GitHub API policy response exceeded the bounded response size") + try: + return json.loads(payload) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise PolicyError("GitHub API returned malformed JSON policy evidence") from exc + + +def _load_changed_files(api_url: str, repository: str, pull_request: int, token: str, opener: OpenJson) -> tuple[ChangedFile, ...]: + """Load every changed-file page while enforcing shape and pagination bounds.""" + + files: list[ChangedFile] = [] + for page in range(1, 32): + url = f"{api_url}/repos/{repository}/pulls/{pull_request}/files?per_page=100&page={page}" + payload = opener(url, token) + if not isinstance(payload, list): + raise PolicyError("GitHub changed-file evidence is not a JSON array") + for item in payload: + if not isinstance(item, Mapping): + raise PolicyError("GitHub changed-file entry is not an object") + path = item.get("filename") + status = item.get("status") + raw_patch = item.get("patch") + patch = "" if raw_patch is None else raw_patch + if ( + not isinstance(path, str) + or not path + or not isinstance(status, str) + or not isinstance(patch, str) + ): + raise PolicyError("GitHub changed-file entry has invalid bounded fields") + files.append( + ChangedFile( + path=path, + status=status, + patch=patch, + patch_available=raw_patch is not None, + ) + ) + if len(files) > 3_000: + raise PolicyError("GitHub changed-file pagination exceeded 3,000 files") + if len(payload) < 100: + return tuple(files) + raise PolicyError("GitHub changed-file pagination exceeded 3,000 files") + + +def _load_file_content(api_url: str, repository: str, path: str, head_sha: str, token: str, opener: OpenJson) -> str: + """Load one final head file as bounded UTF-8 text from the Contents API.""" + + encoded_path = quote(path, safe="/") + url = f"{api_url}/repos/{repository}/contents/{encoded_path}?ref={head_sha}" + payload = opener(url, token) + if not isinstance(payload, Mapping): + raise PolicyError(f"GitHub content evidence for {path} is not an object") + if payload.get("type") != "file" or payload.get("encoding") != "base64": + raise PolicyError(f"GitHub content evidence for {path} is not a regular base64 file") + encoded = payload.get("content") + declared_size = payload.get("size") + if not isinstance(encoded, str) or not isinstance(declared_size, int) or declared_size < 0 or declared_size > MAX_FILE_BYTES: + raise PolicyError(f"GitHub content evidence for {path} exceeds or violates the size contract") + try: + raw = base64.b64decode("".join(encoded.split()), validate=True) + except (ValueError, TypeError) as exc: + raise PolicyError(f"GitHub content evidence for {path} is invalid base64") from exc + if len(raw) != declared_size: + raise PolicyError(f"GitHub content evidence for {path} has a size mismatch") + try: + return raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise PolicyError(f"Runtime policy candidate {path} is not valid UTF-8") from exc + + +def _needs_content_scan(changed: ChangedFile) -> bool: + """Return whether a changed final file can carry an active edge runtime.""" + + if changed.status == "removed" or _is_documentation_or_source_fixture(changed.path): + return False + if not changed.patch_available: + return True + if _runtime_path_rule(changed.path) is not None: + return True + lower_path = changed.path.lower() + if PurePosixPath(lower_path).name in {"dockerfile", "containerfile", "docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"}: + return True + if PurePosixPath(lower_path).suffix in {".conf", ".service", ".yaml", ".yml", ".sh"}: + return True + return "nginx" in changed.patch.lower() + + +def evaluate_pull_request( + *, + api_url: str, + repository: str, + pull_request: int, + head_sha: str, + event_action: str, + token: str, + opener: OpenJson = _github_open_json, +) -> tuple[Violation, ...]: + """Evaluate one pull request without checking out or executing its content.""" + + if event_action == "closed": + return () + if not REPOSITORY_RE.fullmatch(repository): + raise PolicyError("Repository identity is malformed") + if pull_request <= 0: + raise PolicyError("Pull-request number must be positive") + if not SHA_RE.fullmatch(head_sha): + raise PolicyError("Pull-request head SHA is malformed") + if not token: + raise PolicyError("GITHUB_TOKEN is required for policy evidence") + changed_files = _load_changed_files(api_url.rstrip("/"), repository, pull_request, token, opener) + violations: list[Violation] = [] + for changed in changed_files: + if not _needs_content_scan(changed): + continue + content = _load_file_content(api_url.rstrip("/"), repository, changed.path, head_sha, token, opener) + violations.extend(scan_content(changed.path, content)) + return tuple(violations) + + +def _annotation(violation: Violation) -> str: + """Render one bounded GitHub workflow command annotation.""" + + path = violation.path.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A").replace(",", "%2C") + message = f"CWL edge policy requires Cloudflare Pingora; {violation.rule}: {violation.excerpt}" + message = message.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + return f"::error file={path},line={violation.line}::{message}" + + +def build_parser() -> argparse.ArgumentParser: + """Build the command-line parser used by the required workflow.""" + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository", required=True) + parser.add_argument("--pull-request", required=True, type=int) + parser.add_argument("--head-sha", required=True) + parser.add_argument("--event-action", required=True) + parser.add_argument("--api-url", default=GITHUB_API_ORIGIN) + return parser + + +def main(argv: Sequence[str] | None = None, environ: Mapping[str, str] | None = None) -> int: + """Run the policy checker and return a process exit status.""" + + args = build_parser().parse_args(argv) + env = os.environ if environ is None else environ + try: + violations = evaluate_pull_request( + api_url=args.api_url, + repository=args.repository, + pull_request=args.pull_request, + head_sha=args.head_sha, + event_action=args.event_action, + token=env.get("GITHUB_TOKEN", ""), + ) + except PolicyError as exc: + print(f"::error::Pingora edge policy could not establish complete evidence: {exc}") + return 2 + if violations: + for violation in violations: + print(_annotation(violation)) + print(f"CWL Pingora edge policy rejected {len(violations)} active Nginx runtime artifact(s).") + return 1 + print("CWL Pingora edge policy passed: no changed active Nginx runtime artifact remains.") + return 0 + + +if __name__ == "__main__": # pragma: no cover - exercised through main() contract tests + sys.exit(main()) diff --git a/tests/fixtures/pingora_policy_samples.txt b/tests/fixtures/pingora_policy_samples.txt new file mode 100644 index 000000000..2087b92b5 --- /dev/null +++ b/tests/fixtures/pingora_policy_samples.txt @@ -0,0 +1,20 @@ +FROM nginx:1.27-alpine +FROM nginxinc/nginx-unprivileged:1.27 +FROM registry.example:5000/team/nginx:1.27 +FROM nginx/nginx-ingress:1.11 +- image: nginx@sha256:abc +nginx.ingress.kubernetes.io/rewrite-target: / +kubernetes.io/ingress.class: "nginx" +ingressClassName: 'nginx' +CMD ["nginx", "-g", "daemon off;"] +systemctl restart nginx +nginx -s reload +sudo -n nginx -s reload +sudo -u root nginx -s reload +COPY x /etc/nginx/conf.d/default.conf +RUN apk add --no-cache \ + curl \ + nginx +sudo apt-get install nginx +sudo -n apt-get install nginx +sudo --user root apt-get install nginx diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index c5d054e77..1660b2c66 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -407,6 +407,24 @@ def test_opencode_trusted_source_ref_is_not_controlled_by_workflow_inputs(): ) +def test_required_workflow_validates_archive_members_before_extraction(): + """Trusted source materialization rejects traversal and executable archive entries.""" + workflow = Path(".github/workflows/opencode-review.yml").read_text(encoding="utf-8") + start = workflow.index(" - name: Materialize trusted central policy source\n") + end = workflow.index("\n - name:", start + 1) + step = workflow[start:end] + + assert "tar -xzf \"$trusted_archive\"" not in step + assert "tarfile.open(archive_path, \"r:gz\")" in step + assert "PurePosixPath(name).parts" in step + assert "member.isdir()" in step + assert "member.isfile()" in step + assert "unsupported archive member type" in step + assert '".."' in step + assert '"\\\\"' in step + assert 'destination.open("xb")' in step + + def test_opencode_bounded_evidence_context_is_resolved_from_event_payload(): """Avoid putting untrusted PR metadata directly into shell environment keys.""" workflow = Path(".github/workflows/opencode-review-dispatch.yml").read_text(encoding="utf-8") diff --git a/tests/test_pingora_edge_policy.py b/tests/test_pingora_edge_policy.py new file mode 100644 index 000000000..584e54074 --- /dev/null +++ b/tests/test_pingora_edge_policy.py @@ -0,0 +1,466 @@ +"""Regression tests for the organization-wide Pingora edge policy.""" + +from __future__ import annotations + +import base64 +import importlib.util +import sys +from io import BytesIO +from pathlib import Path +from urllib.error import HTTPError, URLError + +import pytest + +MODULE_PATH = Path(__file__).parents[1] / "scripts" / "ci" / "pingora_edge_policy.py" +FIXTURE_PATH = Path(__file__).parent / "fixtures" / "pingora_policy_samples.txt" +SPEC = importlib.util.spec_from_file_location("pingora_edge_policy", MODULE_PATH) +assert SPEC and SPEC.loader +policy = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = policy +SPEC.loader.exec_module(policy) + + +def fixture_text() -> str: + """Return the dedicated source sample used to exercise denied runtime forms.""" + return FIXTURE_PATH.read_text(encoding="utf-8") + + +def encoded_file(content: str, *, size: int | None = None, kind: str = "file", encoding: str = "base64") -> dict[str, object]: + """Build one GitHub Contents API response.""" + + raw = content.encode() + return { + "type": kind, + "encoding": encoding, + "size": len(raw) if size is None else size, + "content": base64.b64encode(raw).decode(), + } + + +def test_scan_content_rejects_runtime_paths_and_every_denied_runtime_form() -> None: + """Runtime filenames and all supported active Nginx forms fail closed.""" + + content = fixture_text() + violations = policy.scan_content("infra/nginx/nginx.conf", content) + rules = {item.rule for item in violations} + assert rules == { + "nginx_runtime_artifact", + "nginx_container_image", + "nginx_ingress_controller", + "nginx_runtime_command", + "nginx_runtime_path", + "nginx_package_install", + } + assert all(item.line >= 1 and item.excerpt for item in violations) + + +def test_scan_content_allows_prose_license_and_source_negative_fixtures() -> None: + """Policy prose, license text, and scanner source fixtures can name Nginx.""" + + sample = fixture_text() + assert policy.scan_content("docs/migration.md", sample) == () + assert policy.scan_content("COPYING", sample) == () + assert policy.scan_content("scripts/ci/pingora_edge_policy.py", sample) == () + assert policy.scan_content("tests/fixtures/policy_samples.py", sample) == () + assert policy.scan_content("tests/fixtures/negative_fixture.rs", sample) == () + assert policy.scan_content("deploy/fixtures/runtime.yaml", sample) + assert policy.scan_content( + "deploy/monitoring.yaml", "image: nginx/nginx-prometheus-exporter:1.0\n" + ) == () + assert policy.scan_content( + "deploy/ingress.yaml", "image: nginx/nginx-ingress:1.11\n" + ) + + +def test_nested_documentation_path_allows_prose_samples() -> None: + """Documentation directories remain exempt when nested below a package.""" + + assert policy.scan_content("packages/component/docs/migration.md", fixture_text()) == () + + +@pytest.mark.parametrize("directory", ["testing", "contests", "assert", "my_tests"]) +def test_scan_content_does_not_treat_test_name_substrings_as_fixtures( + directory: str, +) -> None: + """Only exact test directories are fixture boundaries for active content.""" + + violations = policy.scan_content( + f"{directory}/runtime.py", + "FROM nginx:1.27-alpine\n", + ) + + assert [item.rule for item in violations] == ["nginx_container_image"] + + +def test_runtime_path_rule_covers_script_and_config_shapes() -> None: + """Active Nginx filenames are blocked without relying on their contents.""" + + assert policy._runtime_path_rule("tests/live/nginx.conf") == "nginx_runtime_artifact" + assert policy._runtime_path_rule("ops/nginx-backup.sh") == "nginx_runtime_artifact" + assert policy._runtime_path_rule("infra/nginx/default.yaml") == "nginx_runtime_artifact" + assert policy._runtime_path_rule("config/nginx/default.conf") == "nginx_runtime_artifact" + assert policy._runtime_path_rule("config/nginx.service") == "nginx_runtime_artifact" + assert policy._runtime_path_rule("docs/nginx-history.md") is None + + +def test_needs_content_scan_is_delta_bounded() -> None: + """Removed/prose files skip, while bounded runtime candidates always scan.""" + + changed = policy.ChangedFile + assert not policy._needs_content_scan(changed("Dockerfile", "removed", "+FROM nginx")) + assert not policy._needs_content_scan(changed("README.md", "modified", "+nginx")) + assert policy._needs_content_scan(changed("Dockerfile", "modified", "-FROM nginx\n+FROM scratch")) + assert policy._needs_content_scan(changed("config/runtime.txt", "modified", "+FROM nginx")) + assert policy._needs_content_scan(changed("infra/nginx/default.yaml", "modified", "+server: edge")) + assert policy._needs_content_scan(changed("kubernetes/ingress.yaml", "modified", "+metadata: edge")) + assert policy._needs_content_scan(changed("ops/edge.yaml", "modified", "+metadata: edge")) + assert policy._needs_content_scan(changed("infra/deployment.yaml", "modified", "+image: app")) + assert policy._needs_content_scan(changed("manifests/ingress.yaml", "modified", "+metadata: edge")) + assert policy._needs_content_scan(changed("config/runtime.conf", "modified", "+upstream nginx")) + assert policy._needs_content_scan( + changed("config/runtime.conf", "modified", "", patch_available=False) + ) + assert policy._needs_content_scan(changed("config/runtime.conf", "modified", "+upstream app")) + assert policy._needs_content_scan(changed("src/runtime.go", "modified", "+exec nginx")) + assert not policy._needs_content_scan(changed("src/runtime.go", "modified", "+exec pingora")) + + +def test_active_test_source_is_scanned_while_dedicated_fixtures_are_exempt() -> None: + """Executable test helpers remain candidates; only explicit fixtures are exempt.""" + + violations = policy.scan_content("tests/e2e/start_nginx.py", "systemctl restart nginx\n") + assert [item.rule for item in violations] == ["nginx_runtime_command"] + + +def test_source_identifier_is_not_an_nginx_command() -> None: + """A source-language function name is not an executable shell launch.""" + + assert policy.scan_content("src/runtime.py", "nginx()\n") == () + + +def test_sudo_options_are_supported_for_runtime_commands_and_packages() -> None: + """Bounded sudo flags cannot hide prohibited Nginx operations.""" + + violations = policy.scan_content( + "src/runtime.sh", "sudo -n nginx -s reload\nsudo -n apt-get install nginx\n" + ) + assert {item.rule for item in violations} == { + "nginx_runtime_command", + "nginx_package_install", + } + + +def test_sudo_argument_options_do_not_reinterpret_their_values() -> None: + """Sudo user values do not become false Nginx commands.""" + + violations = policy.scan_content( + "src/runtime.sh", + "sudo -u nginx php-fpm\nsudo -u root nginx -s reload\n" + "sudo --user root apt-get install nginx\n", + ) + assert {item.rule for item in violations} == { + "nginx_runtime_command", + "nginx_package_install", + } + + +def test_untrusted_document_suffix_does_not_bypass_runtime_scan() -> None: + """A runtime-looking file cannot evade policy checks by using a prose suffix.""" + + violations = policy.scan_content("config/runtime.txt", "FROM nginx:1.27-alpine\n") + assert [item.rule for item in violations] == ["nginx_container_image"] + + +def test_evaluate_pull_request_reads_pagination_and_final_content() -> None: + """The checker uses every file page and scans final head content, not removed lines.""" + + calls: list[str] = [] + first_page = [ + {"filename": f"docs/file-{index}.md", "status": "modified", "patch": "+Nginx"} + for index in range(100) + ] + second_page = [ + {"filename": "Dockerfile", "status": "modified", "patch": "-FROM nginx\n+FROM scratch"}, + {"filename": "old/nginx.conf", "status": "removed", "patch": "-server {}"}, + {"filename": "deploy/proxy.yaml", "status": "modified"}, + ] + + def opener(url: str, token: str) -> object: + calls.append(url) + assert token == "token" + if url.endswith("page=1"): + return first_page + if url.endswith("page=2"): + return second_page + if "/contents/Dockerfile" in url: + return encoded_file("FROM scratch\n") + if "/contents/deploy/proxy.yaml" in url: + return encoded_file("image: cwl-pingora-proxy:0.1.0\n") + raise AssertionError(url) + + result = policy.evaluate_pull_request( + api_url="https://api.github.test/", + repository="ContextualWisdomLab/example", + pull_request=7, + head_sha="a" * 40, + event_action="synchronize", + token="token", + opener=opener, + ) + assert result == () + assert any("page=2" in url for url in calls) + assert all("old/nginx.conf" not in url for url in calls) + + +def test_evaluate_pull_request_reports_final_runtime_violation() -> None: + """A changed active runtime image is rejected from final head content.""" + + def opener(url: str, _token: str) -> object: + if "/pulls/9/files" in url: + return [{"filename": "docker-compose.yml", "status": "modified", "patch": "+image: nginx"}] + return encoded_file("services:\n edge:\n image: nginx:1.27-alpine\n") + + result = policy.evaluate_pull_request( + api_url="https://api.github.test", + repository="ContextualWisdomLab/example", + pull_request=9, + head_sha="b" * 40, + event_action="opened", + token="token", + opener=opener, + ) + assert [item.rule for item in result] == ["nginx_container_image"] + + +def test_closed_event_skips_without_credentials_or_identity_validation() -> None: + """Closed-event cleanup remains a no-op for the required-workflow context.""" + + assert policy.evaluate_pull_request( + api_url="x", + repository="bad repo", + pull_request=0, + head_sha="bad", + event_action="closed", + token="", + opener=lambda _url, _token: pytest.fail("must not open"), + ) == () + + +@pytest.mark.parametrize( + ("repository", "pull_request", "head_sha", "token", "message"), + [ + ("bad repo", 1, "a" * 40, "x", "Repository identity"), + ("a/b", 0, "a" * 40, "x", "must be positive"), + ("a/b", 1, "bad", "x", "head SHA"), + ("a/b", 1, "a" * 40, "", "GITHUB_TOKEN"), + ], +) +def test_evaluate_pull_request_rejects_invalid_authority( + repository: str, pull_request: int, head_sha: str, token: str, message: str +) -> None: + """Malformed authority and absent credentials fail before network access.""" + + with pytest.raises(policy.PolicyError, match=message): + policy.evaluate_pull_request( + api_url="x", + repository=repository, + pull_request=pull_request, + head_sha=head_sha, + event_action="opened", + token=token, + opener=lambda _url, _token: pytest.fail("must not open"), + ) + + +@pytest.mark.parametrize( + ("payload", "message"), + [ + ({}, "not a JSON array"), + (["bad"], "entry is not an object"), + ([{"filename": "", "status": "modified", "patch": ""}], "invalid bounded fields"), + ], +) +def test_changed_file_evidence_shape_is_fail_closed(payload: object, message: str) -> None: + """Malformed changed-file API shapes fail closed.""" + + with pytest.raises(policy.PolicyError, match=message): + policy._load_changed_files("api", "a/b", 1, "x", lambda _url, _token: payload) + + +def test_changed_file_pagination_bound_is_fail_closed() -> None: + """More than 3,000 changed files cannot silently truncate policy evidence.""" + + page = [{"filename": f"f-{index}", "status": "modified", "patch": ""} for index in range(100)] + with pytest.raises(policy.PolicyError, match="3,000"): + policy._load_changed_files("api", "a/b", 1, "x", lambda _url, _token: page) + + +def test_changed_file_pagination_accepts_the_inclusive_bound() -> None: + """Exactly 3,000 changed files are accepted only after an empty next page.""" + + page = [ + {"filename": f"f-{index}", "status": "modified", "patch": ""} + for index in range(100) + ] + calls: list[str] = [] + + def opener(url: str, _token: str) -> object: + calls.append(url) + return page if "page=31" not in url else [] + + files = policy._load_changed_files("api", "a/b", 1, "x", opener) + assert len(files) == 3_000 + assert calls[-1].endswith("page=31") + + +@pytest.mark.parametrize( + ("payload", "message"), + [ + ([], "not an object"), + ({"type": "symlink", "encoding": "base64", "size": 0, "content": ""}, "not a regular"), + ({"type": "file", "encoding": "base64", "size": policy.MAX_FILE_BYTES + 1, "content": ""}, "size contract"), + ({"type": "file", "encoding": "base64", "size": 1, "content": "!"}, "invalid base64"), + ({"type": "file", "encoding": "base64", "size": 2, "content": base64.b64encode(b"x").decode()}, "size mismatch"), + ({"type": "file", "encoding": "base64", "size": 1, "content": base64.b64encode(b"\xff").decode()}, "not valid UTF-8"), + ], +) +def test_file_content_evidence_is_fail_closed(payload: object, message: str) -> None: + """Unbounded, nonregular, corrupt, or binary runtime content is rejected.""" + + with pytest.raises(policy.PolicyError, match=message): + policy._load_file_content("api", "a/b", "x y", "a" * 40, "x", lambda url, _token: payload) + + +def test_file_content_loader_quotes_paths() -> None: + """Contents API paths are percent-encoded without losing path separators.""" + + seen: list[str] = [] + + def opener(url: str, _token: str) -> object: + seen.append(url) + return encoded_file("ok") + + assert policy._load_file_content("api", "a/b", "dir/a b.conf", "a" * 40, "x", opener) == "ok" + assert "dir/a%20b.conf" in seen[0] + + +def test_file_content_loader_accepts_wrapped_base64_content() -> None: + """GitHub's line-wrapped Contents API base64 remains valid evidence.""" + + payload = encoded_file("FROM scratch\n") + encoded = str(payload["content"]) + payload["content"] = "\n".join(encoded[index : index + 4] for index in range(0, len(encoded), 4)) + + assert policy._load_file_content( + "api", "a/b", "Dockerfile", "a" * 40, "x", lambda _url, _token: payload + ) == "FROM scratch\n" + + +class FakeResponse: + """Context-managed bounded response for direct opener tests.""" + + def __init__(self, payload: bytes): + self.payload = payload + + def __enter__(self) -> "FakeResponse": + return self + + def __exit__(self, *_args: object) -> None: + return None + + def read(self, _limit: int) -> bytes: + return self.payload + + +def test_github_open_json_accepts_valid_bounded_json(monkeypatch: pytest.MonkeyPatch) -> None: + """The default opener parses bounded GitHub JSON.""" + + monkeypatch.setattr(policy.github_opener, "open", lambda _request, timeout: FakeResponse(b'{"ok": true}')) + assert policy._github_open_json("https://api.github.com/repos/a/b", "token") == {"ok": True} + + +@pytest.mark.parametrize("exc", [URLError("dns"), TimeoutError(), HTTPError("x", 500, "bad", {}, BytesIO())]) +def test_github_open_json_sanitizes_transport_failures(monkeypatch: pytest.MonkeyPatch, exc: Exception) -> None: + """Network failures preserve only their stable class, never response text.""" + + def fail(_request: object, timeout: int) -> object: + assert timeout == 30 + raise exc + + monkeypatch.setattr(policy.github_opener, "open", fail) + with pytest.raises(policy.PolicyError, match=type(exc).__name__): + policy._github_open_json("https://api.github.com/repos/a/b", "token") + + +def test_github_open_json_rejects_oversized_and_malformed_payloads(monkeypatch: pytest.MonkeyPatch) -> None: + """REST evidence must remain one bounded valid JSON document.""" + + monkeypatch.setattr(policy.github_opener, "open", lambda _request, timeout: FakeResponse(b"x" * (policy.MAX_RESPONSE_BYTES + 1))) + with pytest.raises(policy.PolicyError, match="bounded response size"): + policy._github_open_json("https://api.github.com/repos/a/b", "token") + monkeypatch.setattr(policy.github_opener, "open", lambda _request, timeout: FakeResponse(b"not-json")) + with pytest.raises(policy.PolicyError, match="malformed JSON"): + policy._github_open_json("https://api.github.com/repos/a/b", "token") + + +@pytest.mark.parametrize( + "url", + [ + "http://api.github.com/repos/a/b", + "https://evil.example/repos/a/b", + "https://user@api.github.com/repos/a/b", + "https://api.github.com:443/repos/a/b", + "https://api.github.com/user", + "https://api.github.com/repos/a/b#fragment", + ], +) +def test_github_open_json_rejects_nonapproved_origins(url: str) -> None: + """Evidence collection cannot be redirected to attacker-controlled origins.""" + + with pytest.raises(policy.PolicyError, match="approved origin"): + policy._github_open_json(url, "token") + + +def test_github_opener_never_constructs_redirect_requests() -> None: + """The policy opener refuses redirects rather than changing API origins.""" + + assert policy.NoRedirectHandler().redirect_request(None, None, 302, "Found", {}, "https://evil.example") is None + + +def test_annotation_escapes_workflow_command_fields() -> None: + """Workflow annotations cannot be broken by path or excerpt control syntax.""" + + annotation = policy._annotation(policy.Violation("a,b%\n", "rule", 2, "bad%\ntext")) + assert annotation.startswith("::error file=a%2Cb%25%0A,line=2::") + assert "bad%25%0Atext" in annotation + + +def test_main_returns_pass_reject_and_evidence_error(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + """The CLI exposes distinct success, policy rejection, and evidence-error statuses.""" + + base_args = ["--repository", "a/b", "--pull-request", "1", "--head-sha", "a" * 40, "--event-action", "opened"] + monkeypatch.setattr(policy, "evaluate_pull_request", lambda **_kwargs: ()) + assert policy.main(base_args, {"GITHUB_TOKEN": "x"}) == 0 + assert "passed" in capsys.readouterr().out + + violation = policy.Violation("Dockerfile", "nginx_container_image", 1, "FROM nginx") + monkeypatch.setattr(policy, "evaluate_pull_request", lambda **_kwargs: (violation,)) + assert policy.main(base_args, {"GITHUB_TOKEN": "x"}) == 1 + assert "rejected 1" in capsys.readouterr().out + + def evidence_error(**_kwargs: object) -> tuple[object, ...]: + raise policy.PolicyError("unavailable") + + monkeypatch.setattr(policy, "evaluate_pull_request", evidence_error) + assert policy.main(base_args, {"GITHUB_TOKEN": "x"}) == 2 + assert "complete evidence" in capsys.readouterr().out + + +def test_build_parser_uses_pinned_public_api_origin() -> None: + """The CLI defaults to the approved public GitHub API origin.""" + + parser = policy.build_parser() + args = parser.parse_args([ + "--repository", "a/b", "--pull-request", "1", "--head-sha", "a" * 40, "--event-action", "opened" + ]) + assert args.api_url == "https://api.github.com" diff --git a/tests/test_pingora_edge_workflow_contract.py b/tests/test_pingora_edge_workflow_contract.py new file mode 100644 index 000000000..82a85986a --- /dev/null +++ b/tests/test_pingora_edge_workflow_contract.py @@ -0,0 +1,46 @@ +"""Contract tests for organization-wide Pingora enforcement.""" + +from pathlib import Path + + +ROOT = Path(__file__).parents[1] +WORKFLOW = ROOT / ".github" / "workflows" / "opencode-review.yml" + + +def test_required_workflow_enforces_pingora_without_executing_pr_content() -> None: + """The trusted bootstrap must scan API evidence from immutable central code.""" + + text = WORKFLOW.read_text(encoding="utf-8") + assert "pull-requests: read" in text + assert "JOB_CONTEXT_JSON: ${{ toJSON(job) }}" in text + assert "WORKFLOW_SHA: ${{ github.workflow_sha }}" in text + assert "WORKFLOW_REF: ${{ github.workflow_ref }}" in text + assert "GITHUB_CONTEXT_JSON" not in text + assert 'job_context.get("workflow_sha") or os.environ.get("WORKFLOW_SHA")' in text + assert 'workflow_ref.partition("@")' in text + assert 'workflow_repository = "/".join(ref_parts[:2])' in text + assert 'job_context.get("workflow_repository")' not in text + assert "TRUSTED_SOURCE_REF: ${{ steps.trusted_source.outputs.sha }}" in text + assert "repos/ContextualWisdomLab/.github/tarball/${TRUSTED_SOURCE_REF}" in text + assert "Trusted central policy source ref must resolve to the immutable workflow commit SHA" in text + assert "actions/checkout" not in text + assert "scripts/ci/pingora_edge_policy.py" in text + assert '--api-url "https://api.github.com"' in text + assert "secrets:" not in text + + # The required workflow must never turn the untrusted PR head into a source + # checkout ref; it is only an evidence identifier passed to the API scanner. + assert "ref: ${{ github.event.pull_request.head.sha }}" not in text + assert 'TRUSTED_SOURCE_REF: ${{ steps.trusted_source.outputs.sha }}' in text + assert 'tarball/${TRUSTED_SOURCE_REF}' in text + + # The source archive must contain both the workflow and the policy helper, + # and symlinked replacements must fail before the helper is executed. + assert '[ ! -f "$trusted_source_dir/$EXPECTED_FILE" ]' in text + assert '[ -L "$trusted_source_dir/$EXPECTED_FILE" ]' in text + assert '[ ! -f "$trusted_source_dir/scripts/ci/pingora_edge_policy.py" ]' in text + assert '[ -L "$trusted_source_dir/scripts/ci/pingora_edge_policy.py" ]' in text + assert "if: ${{ github.event_name == 'pull_request_target' }}" in text + assert text.index("Verify immutable central policy source") < text.index( + "Enforce Cloudflare Pingora edge policy" + )