diff --git a/.github/workflows/deploy-pages-input-security-ci.yml b/.github/workflows/deploy-pages-input-security-ci.yml new file mode 100644 index 0000000000..fc070d3e65 --- /dev/null +++ b/.github/workflows/deploy-pages-input-security-ci.yml @@ -0,0 +1,46 @@ +name: Deploy Pages Input Security CI + +on: + pull_request: + # Admit feature-base PRs so stacked Pages changes receive current-base evidence. + paths: + - ".github/workflows/deploy-pages.yml" + - ".github/workflows/deploy-pages-input-security-ci.yml" + - "tests/test_deploy_pages_input_shell_boundary.py" + +permissions: + contents: read + +concurrency: + group: deploy-pages-input-security-${{ github.repository }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + pages_input_shell_boundary: + name: pages-input-shell-boundary + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout exact pull request head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 1 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + - name: Verify exact-head Pages shell-input boundary + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha }}" + python -m unittest -q tests/test_deploy_pages_input_shell_boundary.py + python -m compileall -q tests/test_deploy_pages_input_shell_boundary.py diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index f86b614022..60f97438f8 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -61,6 +61,48 @@ jobs: exit 1 fi + - name: Validate deployment inputs + env: + PROJECT_NAME: ${{ inputs.project_name }} + BUILD_DIR: ${{ inputs.build_dir }} + CUSTOM_DOMAIN: ${{ inputs.custom_domain }} + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + set -euo pipefail + + fail_input_validation() { + local input_name="$1" + echo "::error title=Invalid Pages input::${input_name} has an unsafe value." + exit 2 + } + + if [[ ! "${PROJECT_NAME}" =~ ^[a-z0-9]([a-z0-9-]{0,56}[a-z0-9])?$ ]]; then + fail_input_validation "project_name" + fi + + if [[ ! "${BUILD_DIR}" =~ ^(\./)?[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)*$ ]] \ + || [[ "${BUILD_DIR}" == -* ]] \ + || [[ "/${BUILD_DIR}/" == *"/../"* ]]; then + fail_input_validation "build_dir" + fi + + if [[ -n "${CUSTOM_DOMAIN}" ]]; then + if [[ ${#CUSTOM_DOMAIN} -gt 253 ]] \ + || [[ ! "${CUSTOM_DOMAIN}" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ ]] \ + || [[ "${CUSTOM_DOMAIN}" != *.* ]] \ + || [[ "${CUSTOM_DOMAIN}" == *..* ]]; then + fail_input_validation "custom_domain" + fi + IFS='.' read -r -a domain_labels <<< "${CUSTOM_DOMAIN}" + for domain_label in "${domain_labels[@]}"; do + if [[ ${#domain_label} -gt 63 ]] \ + || [[ "${domain_label}" == -* ]] \ + || [[ "${domain_label}" == *- ]]; then + fail_input_validation "custom_domain" + fi + done + fi + - name: Deploy to Cloudflare Pages (wrangler) uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0 with: @@ -100,13 +142,21 @@ jobs: fi fi + # Caller inputs reach the shell through env, never through ${{ }} + # interpolation into the script body: a project name containing shell + # metacharacters would otherwise execute here. Same defect class that + # Semgrep's run-shell-injection rule flags elsewhere in this repo. - name: Summary if: always() + env: + PROJECT_NAME: ${{ inputs.project_name }} + BUILD_DIR: ${{ inputs.build_dir }} + CUSTOM_DOMAIN: ${{ inputs.custom_domain }} run: | { echo "## Cloudflare Pages deploy" echo "" - echo "- **Project:** \`${{ inputs.project_name }}\`" - echo "- **Build dir:** \`${{ inputs.build_dir }}\`" - echo "- **Custom domain:** \`${{ inputs.custom_domain || '(none)' }}\`" + echo "- **Project:** \`${PROJECT_NAME}\`" + echo "- **Build dir:** \`${BUILD_DIR}\`" + echo "- **Custom domain:** \`${CUSTOM_DOMAIN:-(none)}\`" } >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md index 34281625cb..5d5d8fb732 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +### SAST successor restores lost Pages evidence and inherits redirect authority + +- `.github#2272` was briefly force-moved from `4967d66f` to sibling `1ca50644`, dropping the dedicated Pages caller-input security workflow and its executable regression. Before this repair published, a second concurrent rewrite produced `e0b6e70f` with `4967d66f` restored as an ancestor. Ordinary merge `3923b196` keeps that complete current lineage as first parent and stacks the canonical GitHub REST redirect-authority successor `.github#2279@9c19c6e` as second parent. The resulting Draft preserves the Pages `env` shell boundary, its exact-head hosted test, both initial-origin regressions, and the production no-redirect opener/source/tests without another Force Push, scanner suppression, or gate weakening. +- The dedicated Pages acceptance workflow now admits pull requests targeting a stacked feature base instead of filtering only `main`. Its path scope, exact-head checkout, read-only permission, concurrency, and shell-boundary test remain unchanged; the executable contract rejects any future pull-request base-name filter. +- The reusable deploy validates `project_name`, `build_dir`, and `custom_domain` before interpolating them into Wrangler's string-valued `command` input. Shell metacharacters, option-shaped project names, absolute or parent-traversing build paths, malformed domains, and multiline values now fail closed before the credentialed deploy action starts. + ### Noema transport capacity schedules a bounded continuation re-dispatch - After gateway failover, HTTP 429/5xx no longer end only as a permanent required-check failure with `caller attempts=1`. ADR-0031 classifies that class as `provider_capacity_unavailable`, keeps the single gateway request per job, surfaces `provider_attempt_count` from the orchestrator error envelope, and authorizes at most two same-head `repository_dispatch` retries after a capped `Retry-After` or deterministic 60–180 s jitter. Review is never skipped. Refs #2165. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 56f68eb0fc..08fce208a0 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -3425,6 +3425,18 @@ alone -- it is a documented multi-PR hot-file collision zone. Contract: **Evidence / remaining condition.** The standalone fixture mechanism was executed locally against Python stdlib and produced one canonical request followed by terminal HTTP 302 for every hostile target. This is mechanism evidence, not repository acceptance. Final authority requires focused/full exact-tree GREEN, fresh exact-head Security/SAST/Python Security/CodeQL/runtime-quality checks, no unresolved actionable review, ordinary protected-main integration, and downstream consumer validation. No scanner suppression, redirect allowlist widening, provider fallback, workflow gate weakening, or credential-boundary change is included. +## 2026-09-19 SAST successor stack and forced-update carryover + +**Status:** Proposed on `ContextualWisdomLab/.github#2272`; exact-head hosted checks, zero actionable review findings, and qualifying independent approval remain mandatory. + +**Context Map / owner.** The central `.github` CI bounded context owns both the reusable Pages deployment shell boundary and the shared GitHub REST clients. `.github#2279` is the canonical owner lane for GitHub API authority/redirect behavior; `.github#2272` owns the Pages caller-input SAST repair and composes the released owner delta rather than copying an alternate transport implementation. + +**Gap.** The `#2272` head branch moved from `4967d66f303bde675080466e359e75c260a91e06` to sibling `1ca50644a8b3d155b125a5cf24aadeea7cb40a0a`, temporarily losing `.github/workflows/deploy-pages-input-security-ci.yml` and `tests/test_deploy_pages_input_shell_boundary.py`. A concurrent rewrite then restored `4967d66f...` as an ancestor at current `e0b6e70f8c8ea87648af2fc2d34dd43ffa625beb`, but that lineage still retained initial URL admission without `#2279`'s authenticated redirect containment, leaving its live review thread valid. The restored acceptance workflow also restricted `pull_request.branches` to `main`, so retargeting #2272 onto its canonical stacked owner prevented a new current-base Pages run from being admitted. Finally, the regression inspected only `run:` bodies while `cloudflare/wrangler-action` still received caller-controlled `build_dir` and `project_name` through its string-valued `command`; the values could therefore reach the action's command parser without a fail-closed syntax boundary. + +**Action.** Ordinary merge `3923b196daf48f38759b42cd20a70e994ccb7935` retains current `#2272@e0b6e70f...` as first parent, including the restored `4967d66f...` Pages evidence, and integrates canonical owner `#2279@9c19c6e00eafc028068719ab482282c1256f8893` as second parent. The merge selects the stricter exact-authority parser and production no-redirect opener while preserving all Pages workflow/test deltas and the sibling origin-pin tests. The current stack integrates canonical Strix owner #2291 non-destructively and removes only the Pages workflow's mutable base-name filter; a regression test parses the `pull_request` trigger block and rejects any `branches:` restriction. A pre-action validation step now admits only bounded Pages project identifiers, repository-relative build paths without parent traversal, and DNS-shaped custom domains. The validation consumes immutable workflow inputs through `env` and terminates before Wrangler receives credentials or command text. + +**Evidence / remaining condition.** The stack graph is explicit and lossless; no predecessor was closed. The base-admission RED fails only because the exact trigger contains `branches: [main]`; GREEN retains the shell-boundary contract after that filter is removed. The action-command RED fails because no validation step exists; its executable matrix proves ordinary safe values pass while shell metacharacters, option-shaped identifiers, absolute/parent paths, malformed domains, and multiline values are rejected after the repair. This branch must independently pass a newly generated current-base Pages workflow, GitHub authority/redirect suites, full repository tests, Python Security, Security Scan, SAST Semgrep, CodeQL PR, Runtime Quality, and current-head independent review. Predecessor checks and `#2279` receipts do not transfer. No Force Push, destructive rebase, synthetic status, scanner suppression, bypass, or source-neutral wake commit is authorized. + ## 2026-09-20 Strix trusted-binder consumer-isolation gap **Status:** Proposed on `ContextualWisdomLab/.github#2291`; exact-head hosted diff --git a/tests/test_codeql_ghas_configuration_identity.py b/tests/test_codeql_ghas_configuration_identity.py index 817cd56497..7728dbc99c 100644 --- a/tests/test_codeql_ghas_configuration_identity.py +++ b/tests/test_codeql_ghas_configuration_identity.py @@ -495,3 +495,25 @@ def test_list_codeql_analyses_rejects_non_list_payload(monkeypatch): monkeypatch.setattr(identity, "_request_json", lambda url, token, timeout_seconds: {"ok": True}) with pytest.raises(identity.ConfigurationIdentityError): identity.list_codeql_analyses("ContextualWisdomLab/wardnet", token="opaque") + + +def test_request_json_refuses_a_non_github_api_url(): + """The opener is pinned to https://api.github.com before the request is built. + + `_request_json` takes its URL as a plain string. Every caller builds an + api.github.com URL, but the function is what has to enforce it -- an + unexpected caller must not be able to make it fetch another host or another + scheme. The lookalike host matters as much as the scheme: a prefix check + would accept `api.github.com.evil.example`. + """ + assert ( + identity._require_github_api_url("https://api.github.com/repos/o/r") + == "https://api.github.com/repos/o/r" + ) + for rejected in ( + "http://api.github.com/repos/o/r", + "https://api.github.com.evil.example/repos/o/r", + "file:///etc/passwd", + ): + with pytest.raises(identity.ConfigurationIdentityError): + identity._require_github_api_url(rejected) diff --git a/tests/test_deploy_pages_input_shell_boundary.py b/tests/test_deploy_pages_input_shell_boundary.py new file mode 100644 index 0000000000..512eae679d --- /dev/null +++ b/tests/test_deploy_pages_input_shell_boundary.py @@ -0,0 +1,184 @@ +"""Executable shell-boundary contract for the reusable Pages deployment workflow.""" + +from __future__ import annotations + +import os +import re +import subprocess +import textwrap +import unittest +from pathlib import Path + + +WORKFLOW_PATH = Path(__file__).parents[1] / ".github" / "workflows" / "deploy-pages.yml" +ACCEPTANCE_WORKFLOW_PATH = ( + Path(__file__).parents[1] + / ".github" + / "workflows" + / "deploy-pages-input-security-ci.yml" +) +CALLER_INPUT_EXPRESSIONS = { + "PROJECT_NAME": "${{ inputs.project_name }}", + "BUILD_DIR": "${{ inputs.build_dir }}", + "CUSTOM_DOMAIN": "${{ inputs.custom_domain }}", +} + + +def _indented_blocks(text: str, key: str) -> tuple[str, ...]: + """Return literal/folded YAML blocks for ``key`` without requiring a YAML parser.""" + + lines = text.splitlines() + blocks: list[str] = [] + start_re = re.compile(rf"^(?P\s*){re.escape(key)}:\s*[|>][-+]?\s*$") + index = 0 + while index < len(lines): + match = start_re.match(lines[index]) + if match is None: + index += 1 + continue + base_indent = len(match.group("indent")) + index += 1 + body: list[str] = [] + while index < len(lines): + line = lines[index] + if line.strip() and len(line) - len(line.lstrip()) <= base_indent: + break + body.append(line) + index += 1 + blocks.append("\n".join(body)) + return tuple(blocks) + + +def _named_step(text: str, name: str) -> str: + """Return one workflow step block identified by its exact ``name`` field.""" + + lines = text.splitlines() + marker = f"- name: {name}" + for index, line in enumerate(lines): + if line.strip() != marker: + continue + step_indent = len(line) - len(line.lstrip()) + block = [line] + for next_line in lines[index + 1 :]: + if ( + next_line.strip().startswith("- name:") + and len(next_line) - len(next_line.lstrip()) == step_indent + ): + break + block.append(next_line) + return "\n".join(block) + raise AssertionError(f"workflow step not found: {name}") + + +class DeployPagesInputShellBoundaryTests(unittest.TestCase): + """Pin caller-controlled reusable-workflow inputs outside shell source text.""" + + @classmethod + def setUpClass(cls) -> None: + """Read the workflow once from the exact checked-out source tree.""" + + cls.workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + cls.acceptance_workflow = ACCEPTANCE_WORKFLOW_PATH.read_text(encoding="utf-8") + + def test_acceptance_workflow_admits_stacked_pull_request_bases(self) -> None: + """Pages acceptance must not exclude feature-branch PR bases.""" + + lines = self.acceptance_workflow.splitlines() + pull_request_index = lines.index(" pull_request:") + pull_request_block: list[str] = [] + for line in lines[pull_request_index + 1 :]: + if line and not line.startswith(" "): + break + if line.startswith(" ") and not line.startswith(" ") and line.strip(): + break + pull_request_block.append(line) + + self.assertFalse( + any(line.strip().startswith("branches:") for line in pull_request_block) + ) + + def test_caller_inputs_never_interpolate_directly_into_run_scripts(self) -> None: + """Caller-controlled values must cross into shell scripts only through env.""" + + run_blocks = _indented_blocks(self.workflow, "run") + self.assertTrue(run_blocks, "deploy-pages.yml must contain executable run blocks") + for run_script in run_blocks: + for expression in CALLER_INPUT_EXPRESSIONS.values(): + self.assertNotIn(expression, run_script) + + def test_action_command_inputs_are_validated_before_wrangler(self) -> None: + """The string-valued Wrangler command must receive only shell-safe values.""" + + validation_step = _named_step(self.workflow, "Validate deployment inputs") + validation_scripts = _indented_blocks(validation_step, "run") + self.assertEqual(len(validation_scripts), 1) + validation_script = textwrap.dedent(validation_scripts[0]) + + valid_environment = { + **os.environ, + "PROJECT_NAME": "keyverse-marketing", + "BUILD_DIR": "./public/assets_v2", + "CUSTOM_DOMAIN": "pages.example.com", + } + valid_result = subprocess.run( + ["bash", "--noprofile", "--norc", "-o", "pipefail", "-c", validation_script], + check=False, + capture_output=True, + env=valid_environment, + text=True, + ) + self.assertEqual(valid_result.returncode, 0, valid_result.stderr) + + rejected_inputs = ( + ("PROJECT_NAME", "safe; touch /tmp/pages-command-injection"), + ("PROJECT_NAME", "--config=attacker.toml"), + ("BUILD_DIR", "./public && printf injected"), + ("BUILD_DIR", "../private"), + ("BUILD_DIR", "/tmp/public"), + ("CUSTOM_DOMAIN", "safe.example; printf injected"), + ("CUSTOM_DOMAIN", "line-one\nline-two.example"), + ) + for environment_name, hostile_value in rejected_inputs: + hostile_environment = {**valid_environment, environment_name: hostile_value} + hostile_result = subprocess.run( + [ + "bash", + "--noprofile", + "--norc", + "-o", + "pipefail", + "-c", + validation_script, + ], + check=False, + capture_output=True, + env=hostile_environment, + text=True, + ) + self.assertNotEqual( + hostile_result.returncode, + 0, + f"accepted hostile {environment_name}={hostile_value!r}", + ) + + self.assertLess( + self.workflow.index("- name: Validate deployment inputs"), + self.workflow.index("- name: Deploy to Cloudflare Pages (wrangler)"), + ) + + def test_summary_binds_caller_inputs_through_environment(self) -> None: + """The summary step consumes caller values from named environment variables.""" + + summary = _named_step(self.workflow, "Summary") + for variable, expression in CALLER_INPUT_EXPRESSIONS.items(): + self.assertRegex( + summary, + rf"(?m)^\s+{re.escape(variable)}:\s+{re.escape(expression)}\s*$", + ) + self.assertIn("${PROJECT_NAME}", summary) + self.assertIn("${BUILD_DIR}", summary) + self.assertIn("${CUSTOM_DOMAIN:-(none)}", summary) + + +if __name__ == "__main__": # pragma: no cover - CI uses unittest discovery directly. + unittest.main() diff --git a/tests/test_strix_evidence_binding.py b/tests/test_strix_evidence_binding.py index 90a5454ecb..d460744b6b 100644 --- a/tests/test_strix_evidence_binding.py +++ b/tests/test_strix_evidence_binding.py @@ -969,3 +969,38 @@ def test_workspace_missing_root_returns_false(tmp_path: Path) -> None: missing = tmp_path / "missing-root" assert binding.workspace_contains_expected_diff(missing, "a.py", "body") is False + + +def test_default_github_opener_refuses_a_non_github_origin() -> None: + """The opener takes a string, so it must pin the origin itself. + + Without this, an unexpected caller could make it fetch any scheme or host, + including file:// or an internal address. Semgrep's dynamic-urllib audit + rule is what surfaced the gap. + """ + import importlib.util + import sys + from pathlib import Path + + spec = importlib.util.spec_from_file_location( + "strix_evidence_binding", Path("scripts/ci/strix_evidence_binding.py") + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules["strix_evidence_binding"] = module + spec.loader.exec_module(module) + + assert ( + module._require_github_api_url("https://api.github.com/repos/o/r") + == "https://api.github.com/repos/o/r" + ) + for rejected in ( + "http://api.github.com/repos/o/r", + "https://api.github.com.evil.example/repos/o/r", + "file:///etc/passwd", + ): + try: + module._require_github_api_url(rejected) + except module.EvidenceBindingError: + continue + raise AssertionError(f"{rejected} was not rejected")