Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5b9e864
fix(sast): clear the three Semgrep findings that fail every PR here
seonghobae Sep 18, 2026
bb9413a
test(security): pin Pages caller-input shell boundary
seonghobae Sep 18, 2026
4967d66
test(security): execute Pages shell-input regression
seonghobae Sep 19, 2026
ba7f41f
fix(sast): silence Bandit B310 on the same two hardened openers
Sep 19, 2026
e0b6e70
test(sast): cover the codeql opener's origin pin, not just strix's
Sep 19, 2026
3923b19
merge: integrate GitHub API redirect authority successor
seonghobae Sep 19, 2026
5896e60
docs(sast): record lossless successor stack
seonghobae Sep 19, 2026
5c71e88
merge: carry current GitHub authority evidence into SAST repair
seonghobae Sep 19, 2026
cd3b41b
merge: carry current GitHub authority owner into SAST repair
seonghobae Sep 19, 2026
857e788
test(strix): require fixture evidence binder
seonghobae Sep 19, 2026
89cee55
fix(strix): materialize evidence binder in fixtures
seonghobae Sep 19, 2026
1eb03c7
docs(strix): record fixture runtime RCA
seonghobae Sep 19, 2026
8f66ead
test(strix): require complete isolated fixture runtime
seonghobae Sep 19, 2026
354692e
merge: adopt protected main before Strix repair
seonghobae Sep 19, 2026
4e8829f
fix(strix): restore and close isolated fixture runtime
seonghobae Sep 19, 2026
0379535
merge: stack Pages SAST lane on canonical Strix owner
seonghobae Sep 19, 2026
b0f6d72
merge: integrate canonical Strix fixture head
seonghobae Sep 20, 2026
6ac3d96
fix(pages): admit stacked pull request bases
seonghobae Sep 20, 2026
75c5deb
test(pages): reproduce action command input gap
seonghobae Sep 20, 2026
f5b96a4
fix(pages): validate inputs before Wrangler command
seonghobae Sep 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/deploy-pages-input-security-ci.yml
Original file line number Diff line number Diff line change
@@ -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
56 changes: 53 additions & 3 deletions .github/workflows/deploy-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
12 changes: 12 additions & 0 deletions docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions tests/test_codeql_ghas_configuration_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
184 changes: 184 additions & 0 deletions tests/test_deploy_pages_input_shell_boundary.py
Original file line number Diff line number Diff line change
@@ -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<indent>\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()
Loading
Loading