diff --git a/.github/workflows/cd-apply.yml b/.github/workflows/cd-apply.yml index cdb978c..f32efee 100644 --- a/.github/workflows/cd-apply.yml +++ b/.github/workflows/cd-apply.yml @@ -19,6 +19,7 @@ jobs: timeout-minutes: 5 outputs: contract_sha: ${{ steps.authorize.outputs.contract_sha }} + private_runner_allowed: ${{ steps.authorize.outputs.private_runner_allowed }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -31,13 +32,30 @@ jobs: id: authorize env: CONTRACT_SHA: ${{ inputs.contract_sha }} + CALLER_PRIVATE: ${{ github.event.repository.private }} + CALLER_EVENT: ${{ github.event_name }} + CALLER_REF: ${{ github.ref }} + CALLER_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + REQUIRES_PRIVATE_RUNNER: "true" run: | + set -euo pipefail + private_runner_allowed=false + if [[ "$CALLER_PRIVATE" == true && -n "$CALLER_DEFAULT_BRANCH" && + "$CALLER_REF" == "refs/heads/$CALLER_DEFAULT_BRANCH" && + "$CALLER_EVENT" =~ ^(push|workflow_dispatch|schedule)$ ]]; then + private_runner_allowed=true + fi + if [[ "$REQUIRES_PRIVATE_RUNNER" == true && "$private_runner_allowed" != true ]]; then + echo 'Private CD runners require a trusted default-branch push, manual or scheduled caller.' >&2 + exit 1 + fi [[ "$CONTRACT_SHA" =~ ^[0-9a-f]{40}$ ]] git -C authority fetch --no-tags --depth=1 origin "$CONTRACT_SHA" git -C authority cat-file -e "$CONTRACT_SHA^{commit}" git -C authority merge-base --is-ancestor "$CONTRACT_SHA" refs/remotes/origin/main - printf 'contract_sha=%s\n' "$CONTRACT_SHA" >> "$GITHUB_OUTPUT" + printf 'contract_sha=%s\nprivate_runner_allowed=%s\n' "$CONTRACT_SHA" "$private_runner_allowed" >> "$GITHUB_OUTPUT" apply: + if: ${{ needs.authorize-contract.outputs.private_runner_allowed == 'true' }} needs: authorize-contract runs-on: [self-hosted, cd-apply-out-of-band] permissions: @@ -59,6 +77,7 @@ jobs: STATE_PATH: ${{ inputs.state_path }} APPROVAL_PATH: ${{ inputs.approval_path }} run: | + set -euo pipefail [[ "$CONTRACT_SHA" =~ ^[0-9a-f]{40}$ ]] [[ "$CONTRACT_SHA" == "$AUTHORIZED_CONTRACT_SHA" ]] for value in "$PLAN_PATH" "$STATE_PATH" "$APPROVAL_PATH"; do @@ -67,7 +86,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: NDDev-OpenNetwork/cd-workflows - ref: ${{ inputs.contract_sha }} + ref: ${{ needs.authorize-contract.outputs.contract_sha }} persist-credentials: false path: _cd - name: Bind requested deployment diff --git a/.github/workflows/cd-evidence.yml b/.github/workflows/cd-evidence.yml index 443d31f..e1aa398 100644 --- a/.github/workflows/cd-evidence.yml +++ b/.github/workflows/cd-evidence.yml @@ -18,6 +18,7 @@ jobs: timeout-minutes: 5 outputs: contract_sha: ${{ steps.authorize.outputs.contract_sha }} + private_runner_allowed: ${{ steps.authorize.outputs.private_runner_allowed }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -30,13 +31,28 @@ jobs: id: authorize env: CONTRACT_SHA: ${{ inputs.contract_sha }} + CALLER_PRIVATE: ${{ github.event.repository.private }} + CALLER_EVENT: ${{ github.event_name }} + CALLER_REF: ${{ github.ref }} + CALLER_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + REQUIRES_PRIVATE_RUNNER: "false" run: | + set -euo pipefail + private_runner_allowed=false + if [[ "$CALLER_PRIVATE" == true && -n "$CALLER_DEFAULT_BRANCH" && + "$CALLER_REF" == "refs/heads/$CALLER_DEFAULT_BRANCH" && + "$CALLER_EVENT" =~ ^(push|workflow_dispatch|schedule)$ ]]; then + private_runner_allowed=true + fi + if [[ "$REQUIRES_PRIVATE_RUNNER" == true && "$private_runner_allowed" != true ]]; then + echo 'Private CD runners require a trusted default-branch push, manual or scheduled caller.' >&2 + exit 1 + fi [[ "$CONTRACT_SHA" =~ ^[0-9a-f]{40}$ ]] - [[ "$CONTRACT_SHA" == "$AUTHORIZED_CONTRACT_SHA" ]] git -C authority fetch --no-tags --depth=1 origin "$CONTRACT_SHA" git -C authority cat-file -e "$CONTRACT_SHA^{commit}" git -C authority merge-base --is-ancestor "$CONTRACT_SHA" refs/remotes/origin/main - printf 'contract_sha=%s\n' "$CONTRACT_SHA" >> "$GITHUB_OUTPUT" + printf 'contract_sha=%s\nprivate_runner_allowed=%s\n' "$CONTRACT_SHA" "$private_runner_allowed" >> "$GITHUB_OUTPUT" validate: needs: authorize-contract @@ -50,12 +66,14 @@ jobs: - name: Validate immutable inputs before contract checkout env: {CONTRACT_SHA: "${{ inputs.contract_sha }}", AUTHORIZED_CONTRACT_SHA: "${{ needs.authorize-contract.outputs.contract_sha }}", PLAN_PATH: "${{ inputs.plan_path }}", STATE_PATH: "${{ inputs.state_path }}", EVIDENCE_PATH: "${{ inputs.evidence_path }}"} run: | + set -euo pipefail [[ "$CONTRACT_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$CONTRACT_SHA" == "$AUTHORIZED_CONTRACT_SHA" ]] for value in "$PLAN_PATH" "$STATE_PATH" "$EVIDENCE_PATH"; do [[ "$value" =~ ^[A-Za-z0-9._/-]+$ && "$value" != /* && "$value" != *..* ]] done - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: {repository: NDDev-OpenNetwork/cd-workflows, ref: "${{ inputs.contract_sha }}", persist-credentials: false, path: _cd} + with: {repository: NDDev-OpenNetwork/cd-workflows, ref: "${{ needs.authorize-contract.outputs.contract_sha }}", persist-credentials: false, path: _cd} - uses: ./_cd/.github/actions/contract with: command: validate-evidence diff --git a/.github/workflows/cd-plan.yml b/.github/workflows/cd-plan.yml index 9579903..7eac59c 100644 --- a/.github/workflows/cd-plan.yml +++ b/.github/workflows/cd-plan.yml @@ -30,6 +30,7 @@ jobs: timeout-minutes: 5 outputs: contract_sha: ${{ steps.authorize.outputs.contract_sha }} + private_runner_allowed: ${{ steps.authorize.outputs.private_runner_allowed }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -42,13 +43,28 @@ jobs: id: authorize env: CONTRACT_SHA: ${{ inputs.contract_sha }} + CALLER_PRIVATE: ${{ github.event.repository.private }} + CALLER_EVENT: ${{ github.event_name }} + CALLER_REF: ${{ github.ref }} + CALLER_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + REQUIRES_PRIVATE_RUNNER: ${{ inputs.execution_surface == 'out-of-band' }} run: | + set -euo pipefail + private_runner_allowed=false + if [[ "$CALLER_PRIVATE" == true && -n "$CALLER_DEFAULT_BRANCH" && + "$CALLER_REF" == "refs/heads/$CALLER_DEFAULT_BRANCH" && + "$CALLER_EVENT" =~ ^(push|workflow_dispatch|schedule)$ ]]; then + private_runner_allowed=true + fi + if [[ "$REQUIRES_PRIVATE_RUNNER" == true && "$private_runner_allowed" != true ]]; then + echo 'Private CD runners require a trusted default-branch push, manual or scheduled caller.' >&2 + exit 1 + fi [[ "$CONTRACT_SHA" =~ ^[0-9a-f]{40}$ ]] - [[ "$CONTRACT_SHA" == "$AUTHORIZED_CONTRACT_SHA" ]] git -C authority fetch --no-tags --depth=1 origin "$CONTRACT_SHA" git -C authority cat-file -e "$CONTRACT_SHA^{commit}" git -C authority merge-base --is-ancestor "$CONTRACT_SHA" refs/remotes/origin/main - printf 'contract_sha=%s\n' "$CONTRACT_SHA" >> "$GITHUB_OUTPUT" + printf 'contract_sha=%s\nprivate_runner_allowed=%s\n' "$CONTRACT_SHA" "$private_runner_allowed" >> "$GITHUB_OUTPUT" hosted: if: ${{ inputs.execution_surface == 'hosted' }} @@ -77,7 +93,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: NDDev-OpenNetwork/cd-workflows - ref: ${{ inputs.contract_sha }} + ref: ${{ needs.authorize-contract.outputs.contract_sha }} persist-credentials: false path: _cd - name: Seal immutable plan @@ -98,7 +114,7 @@ jobs: retention-days: 30 out-of-band: - if: ${{ inputs.execution_surface == 'out-of-band' }} + if: ${{ inputs.execution_surface == 'out-of-band' && needs.authorize-contract.outputs.private_runner_allowed == 'true' }} needs: authorize-contract permissions: contents: read @@ -124,7 +140,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: NDDev-OpenNetwork/cd-workflows - ref: ${{ inputs.contract_sha }} + ref: ${{ needs.authorize-contract.outputs.contract_sha }} persist-credentials: false path: _cd - name: Seal immutable plan diff --git a/.github/workflows/cd-resume.yml b/.github/workflows/cd-resume.yml index 015e547..f98774c 100644 --- a/.github/workflows/cd-resume.yml +++ b/.github/workflows/cd-resume.yml @@ -19,6 +19,7 @@ jobs: timeout-minutes: 5 outputs: contract_sha: ${{ steps.authorize.outputs.contract_sha }} + private_runner_allowed: ${{ steps.authorize.outputs.private_runner_allowed }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -31,15 +32,31 @@ jobs: id: authorize env: CONTRACT_SHA: ${{ inputs.contract_sha }} + CALLER_PRIVATE: ${{ github.event.repository.private }} + CALLER_EVENT: ${{ github.event_name }} + CALLER_REF: ${{ github.ref }} + CALLER_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + REQUIRES_PRIVATE_RUNNER: "true" run: | + set -euo pipefail + private_runner_allowed=false + if [[ "$CALLER_PRIVATE" == true && -n "$CALLER_DEFAULT_BRANCH" && + "$CALLER_REF" == "refs/heads/$CALLER_DEFAULT_BRANCH" && + "$CALLER_EVENT" =~ ^(push|workflow_dispatch|schedule)$ ]]; then + private_runner_allowed=true + fi + if [[ "$REQUIRES_PRIVATE_RUNNER" == true && "$private_runner_allowed" != true ]]; then + echo 'Private CD runners require a trusted default-branch push, manual or scheduled caller.' >&2 + exit 1 + fi [[ "$CONTRACT_SHA" =~ ^[0-9a-f]{40}$ ]] - [[ "$CONTRACT_SHA" == "$AUTHORIZED_CONTRACT_SHA" ]] git -C authority fetch --no-tags --depth=1 origin "$CONTRACT_SHA" git -C authority cat-file -e "$CONTRACT_SHA^{commit}" git -C authority merge-base --is-ancestor "$CONTRACT_SHA" refs/remotes/origin/main - printf 'contract_sha=%s\n' "$CONTRACT_SHA" >> "$GITHUB_OUTPUT" + printf 'contract_sha=%s\nprivate_runner_allowed=%s\n' "$CONTRACT_SHA" "$private_runner_allowed" >> "$GITHUB_OUTPUT" resume: + if: ${{ needs.authorize-contract.outputs.private_runner_allowed == 'true' }} needs: authorize-contract permissions: contents: read @@ -56,12 +73,14 @@ jobs: - name: Validate immutable inputs before contract checkout env: {CONTRACT_SHA: "${{ inputs.contract_sha }}", AUTHORIZED_CONTRACT_SHA: "${{ needs.authorize-contract.outputs.contract_sha }}", PLAN_PATH: "${{ inputs.plan_path }}", STATE_PATH: "${{ inputs.state_path }}", APPROVAL_PATH: "${{ inputs.approval_path }}"} run: | + set -euo pipefail [[ "$CONTRACT_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$CONTRACT_SHA" == "$AUTHORIZED_CONTRACT_SHA" ]] for value in "$PLAN_PATH" "$STATE_PATH" "$APPROVAL_PATH"; do [[ "$value" =~ ^[A-Za-z0-9._/-]+$ && "$value" != /* && "$value" != *..* ]] done - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: {repository: NDDev-OpenNetwork/cd-workflows, ref: "${{ inputs.contract_sha }}", persist-credentials: false, path: _cd} + with: {repository: NDDev-OpenNetwork/cd-workflows, ref: "${{ needs.authorize-contract.outputs.contract_sha }}", persist-credentials: false, path: _cd} - name: Bind requested deployment env: {DEPLOYMENT_ID: "${{ inputs.deployment_id }}", PLAN: "source/${{ inputs.plan_path }}"} run: '[[ "$DEPLOYMENT_ID" =~ ^deploy_[0-9A-Z]{26}$ ]] && [[ "$(jq -er .deployment_id "$PLAN")" == "$DEPLOYMENT_ID" ]]' diff --git a/.github/workflows/cd-rollback.yml b/.github/workflows/cd-rollback.yml index 76546ef..687d49d 100644 --- a/.github/workflows/cd-rollback.yml +++ b/.github/workflows/cd-rollback.yml @@ -19,6 +19,7 @@ jobs: timeout-minutes: 5 outputs: contract_sha: ${{ steps.authorize.outputs.contract_sha }} + private_runner_allowed: ${{ steps.authorize.outputs.private_runner_allowed }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -31,15 +32,31 @@ jobs: id: authorize env: CONTRACT_SHA: ${{ inputs.contract_sha }} + CALLER_PRIVATE: ${{ github.event.repository.private }} + CALLER_EVENT: ${{ github.event_name }} + CALLER_REF: ${{ github.ref }} + CALLER_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + REQUIRES_PRIVATE_RUNNER: "true" run: | + set -euo pipefail + private_runner_allowed=false + if [[ "$CALLER_PRIVATE" == true && -n "$CALLER_DEFAULT_BRANCH" && + "$CALLER_REF" == "refs/heads/$CALLER_DEFAULT_BRANCH" && + "$CALLER_EVENT" =~ ^(push|workflow_dispatch|schedule)$ ]]; then + private_runner_allowed=true + fi + if [[ "$REQUIRES_PRIVATE_RUNNER" == true && "$private_runner_allowed" != true ]]; then + echo 'Private CD runners require a trusted default-branch push, manual or scheduled caller.' >&2 + exit 1 + fi [[ "$CONTRACT_SHA" =~ ^[0-9a-f]{40}$ ]] - [[ "$CONTRACT_SHA" == "$AUTHORIZED_CONTRACT_SHA" ]] git -C authority fetch --no-tags --depth=1 origin "$CONTRACT_SHA" git -C authority cat-file -e "$CONTRACT_SHA^{commit}" git -C authority merge-base --is-ancestor "$CONTRACT_SHA" refs/remotes/origin/main - printf 'contract_sha=%s\n' "$CONTRACT_SHA" >> "$GITHUB_OUTPUT" + printf 'contract_sha=%s\nprivate_runner_allowed=%s\n' "$CONTRACT_SHA" "$private_runner_allowed" >> "$GITHUB_OUTPUT" rollback: + if: ${{ needs.authorize-contract.outputs.private_runner_allowed == 'true' }} needs: authorize-contract permissions: contents: read @@ -56,12 +73,14 @@ jobs: - name: Validate immutable inputs before contract checkout env: {CONTRACT_SHA: "${{ inputs.contract_sha }}", AUTHORIZED_CONTRACT_SHA: "${{ needs.authorize-contract.outputs.contract_sha }}", PLAN_PATH: "${{ inputs.plan_path }}", STATE_PATH: "${{ inputs.state_path }}", APPROVAL_PATH: "${{ inputs.approval_path }}"} run: | + set -euo pipefail [[ "$CONTRACT_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$CONTRACT_SHA" == "$AUTHORIZED_CONTRACT_SHA" ]] for value in "$PLAN_PATH" "$STATE_PATH" "$APPROVAL_PATH"; do [[ "$value" =~ ^[A-Za-z0-9._/-]+$ && "$value" != /* && "$value" != *..* ]] done - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: {repository: NDDev-OpenNetwork/cd-workflows, ref: "${{ inputs.contract_sha }}", persist-credentials: false, path: _cd} + with: {repository: NDDev-OpenNetwork/cd-workflows, ref: "${{ needs.authorize-contract.outputs.contract_sha }}", persist-credentials: false, path: _cd} - name: Bind requested deployment env: {DEPLOYMENT_ID: "${{ inputs.deployment_id }}", PLAN: "source/${{ inputs.plan_path }}"} run: '[[ "$DEPLOYMENT_ID" =~ ^deploy_[0-9A-Z]{26}$ ]] && [[ "$(jq -er .deployment_id "$PLAN")" == "$DEPLOYMENT_ID" ]]' diff --git a/.github/workflows/cd-verify.yml b/.github/workflows/cd-verify.yml index 4d61666..bb465e5 100644 --- a/.github/workflows/cd-verify.yml +++ b/.github/workflows/cd-verify.yml @@ -18,6 +18,7 @@ jobs: timeout-minutes: 5 outputs: contract_sha: ${{ steps.authorize.outputs.contract_sha }} + private_runner_allowed: ${{ steps.authorize.outputs.private_runner_allowed }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -30,15 +31,31 @@ jobs: id: authorize env: CONTRACT_SHA: ${{ inputs.contract_sha }} + CALLER_PRIVATE: ${{ github.event.repository.private }} + CALLER_EVENT: ${{ github.event_name }} + CALLER_REF: ${{ github.ref }} + CALLER_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + REQUIRES_PRIVATE_RUNNER: "true" run: | + set -euo pipefail + private_runner_allowed=false + if [[ "$CALLER_PRIVATE" == true && -n "$CALLER_DEFAULT_BRANCH" && + "$CALLER_REF" == "refs/heads/$CALLER_DEFAULT_BRANCH" && + "$CALLER_EVENT" =~ ^(push|workflow_dispatch|schedule)$ ]]; then + private_runner_allowed=true + fi + if [[ "$REQUIRES_PRIVATE_RUNNER" == true && "$private_runner_allowed" != true ]]; then + echo 'Private CD runners require a trusted default-branch push, manual or scheduled caller.' >&2 + exit 1 + fi [[ "$CONTRACT_SHA" =~ ^[0-9a-f]{40}$ ]] - [[ "$CONTRACT_SHA" == "$AUTHORIZED_CONTRACT_SHA" ]] git -C authority fetch --no-tags --depth=1 origin "$CONTRACT_SHA" git -C authority cat-file -e "$CONTRACT_SHA^{commit}" git -C authority merge-base --is-ancestor "$CONTRACT_SHA" refs/remotes/origin/main - printf 'contract_sha=%s\n' "$CONTRACT_SHA" >> "$GITHUB_OUTPUT" + printf 'contract_sha=%s\nprivate_runner_allowed=%s\n' "$CONTRACT_SHA" "$private_runner_allowed" >> "$GITHUB_OUTPUT" verify: + if: ${{ needs.authorize-contract.outputs.private_runner_allowed == 'true' }} needs: authorize-contract permissions: contents: read @@ -53,12 +70,14 @@ jobs: - name: Validate immutable inputs before contract checkout env: {CONTRACT_SHA: "${{ inputs.contract_sha }}", AUTHORIZED_CONTRACT_SHA: "${{ needs.authorize-contract.outputs.contract_sha }}", PLAN_PATH: "${{ inputs.plan_path }}", STATE_PATH: "${{ inputs.state_path }}"} run: | + set -euo pipefail [[ "$CONTRACT_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$CONTRACT_SHA" == "$AUTHORIZED_CONTRACT_SHA" ]] for value in "$PLAN_PATH" "$STATE_PATH"; do [[ "$value" =~ ^[A-Za-z0-9._/-]+$ && "$value" != /* && "$value" != *..* ]] done - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: {repository: NDDev-OpenNetwork/cd-workflows, ref: "${{ inputs.contract_sha }}", persist-credentials: false, path: _cd} + with: {repository: NDDev-OpenNetwork/cd-workflows, ref: "${{ needs.authorize-contract.outputs.contract_sha }}", persist-credentials: false, path: _cd} - name: Bind requested deployment env: {DEPLOYMENT_ID: "${{ inputs.deployment_id }}", PLAN: "source/${{ inputs.plan_path }}"} run: '[[ "$DEPLOYMENT_ID" =~ ^deploy_[0-9A-Z]{26}$ ]] && [[ "$(jq -er .deployment_id "$PLAN")" == "$DEPLOYMENT_ID" ]]' diff --git a/CHANGELOG.md b/CHANGELOG.md index 737faa4..53d299d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ Versioning. ## [Unreleased] +- Repair five contract-authorization workflows that compared an input with an + unset authorization output before producing it. Execute regression tests + against the actual shell blocks and use the verified output for every contract + checkout. Restrict private CD runners to trusted default-branch callers. + - Select continuous-development so ordinary merge does not wait on the `test` GitHub required check; local `verification.required` stays. diff --git a/README.md b/README.md index 769580c..532ef5e 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,19 @@ non-cancelling per-deployment serialization. `cd-verify.yml` uses the separate `cd-verify-out-of-band` surface. Every adapter result must validate as an exact plan-bound state transition and content-addressed evidence record before the workflow can succeed. `cd-evidence.yml` provides a hosted, read-only verifier. -Before `cd-apply` can reach its privileged runner, a GitHub-hosted gate fetches +Before any entrypoint checks out contract code, a GitHub-hosted gate fetches the requested contract commit as data and proves it is reachable from the module's reviewed `main`; an unmerged or fork-only SHA fails before OIDC or -self-hosted capacity is granted. +self-hosted capacity is granted. Contract checkouts consume only that gate's +authorized output. The gate executes no candidate code. + +Out-of-band plan, apply, resume, rollback and verify additionally require a +private caller repository and a push, manual dispatch or schedule on its default +branch. Public repositories, PR events (including privileged PR events), tags, +non-default branches and workflow-run callbacks cannot schedule these runners. +Hosted plan/evidence validation remains available for untrusted callers. +Deployment approvals and the installed adapter's own target authorization still +apply after this caller gate. ## Trust boundaries diff --git a/scripts/validate_module.sh b/scripts/validate_module.sh index cda85be..490deeb 100755 --- a/scripts/validate_module.sh +++ b/scripts/validate_module.sh @@ -45,7 +45,7 @@ for required in ( "inputs.execution_surface == 'hosted'", "inputs.execution_surface == 'out-of-band'", "repository: NDDev-OpenNetwork/cd-workflows", - "ref: ${{ inputs.contract_sha }}", + "ref: ${{ needs.authorize-contract.outputs.contract_sha }}", "retention-days: 30", ): if required not in plan_workflow: @@ -99,7 +99,7 @@ for name in ("apply", "verify", "resume", "rollback", "evidence", "plan"): raise SystemExit(f"cd-{name} workflow lacks provenance control {required!r}") if content.index("authorize-contract:") > content.index(first_job[name]): raise SystemExit(f"cd-{name} privileged job appears before contract authorization") - if content.count("needs: authorize-contract") < content.count("ref: ${{ inputs.contract_sha }}"): + if content.count("needs: authorize-contract") < content.count("ref: ${{ needs.authorize-contract.outputs.contract_sha }}"): raise SystemExit(f"cd-{name} checks out the contract in a job that skipped authorization") if name == "verify" and "runs-on: [self-hosted, cd-verify-out-of-band]" not in content: raise SystemExit("cd-verify workflow is not independent of the managed fleet") diff --git a/tests/test_contract_sha_provenance.py b/tests/test_contract_sha_provenance.py index 6377d85..84cf929 100644 --- a/tests/test_contract_sha_provenance.py +++ b/tests/test_contract_sha_provenance.py @@ -1,10 +1,65 @@ import pathlib +import os +import re import subprocess import tempfile +import textwrap import unittest class ContractSHAProvenanceTests(unittest.TestCase): + def test_actual_authorization_scripts_accept_reviewed_commit_and_refuse_foreign_source(self): + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + repository = root / "origin" + repository.mkdir() + self.git(repository, "init", "-b", "main") + self.git(repository, "config", "user.name", "Example") + self.git(repository, "config", "user.email", "example@example.invalid") + reviewed = self.commit(repository, "reviewed") + self.git(repository, "checkout", "-b", "unreviewed") + foreign = self.commit(repository, "foreign") + self.git(repository, "checkout", "main") + self.git(root, "clone", str(repository), "authority") + for path in pathlib.Path(".github/workflows").glob("cd-*.yml"): + content = path.read_text() + if "Authorize reviewed contract ancestry" not in content: + continue + step = content.split("Authorize reviewed contract ancestry", 1)[1] + # Read the full YAML block by its indentation, not just its first line. + lines = step.split(" run: |\n", 1)[1].splitlines() + block = [] + for line in lines: + if line.strip() and not line.startswith(" "): + break + block.append(line) + script = textwrap.dedent("\n".join(block)) + scenarios = [(reviewed, True, {}), (foreign, False, {}), ("not-an-oid", False, {})] + scenarios.extend((reviewed, False, overrides) for overrides in ( + {"CALLER_PRIVATE": "false"}, {"CALLER_EVENT": "pull_request"}, + {"CALLER_EVENT": "pull_request_target"}, {"CALLER_EVENT": "workflow_run"}, + {"CALLER_REF": "refs/heads/unreviewed"}, {"CALLER_DEFAULT_BRANCH": ""}, + {"CALLER_REF": "refs/tags/v1.0.0"}, + )) + scenarios.extend((reviewed, True, {"CALLER_EVENT": event}) + for event in ("workflow_dispatch", "schedule")) + scenarios.append((reviewed, True, {"REQUIRES_PRIVATE_RUNNER": "false", + "CALLER_PRIVATE": "false", "CALLER_EVENT": "pull_request"})) + for sha, succeeds, overrides in scenarios: + with self.subTest(workflow=path.name, sha=sha): + output = root / "output" + output.write_text("") + env = {**os.environ, "CONTRACT_SHA": sha, "GITHUB_OUTPUT": str(output), + "CALLER_PRIVATE": "true", "CALLER_EVENT": "push", + "CALLER_REF": "refs/heads/main", "CALLER_DEFAULT_BRANCH": "main", + "REQUIRES_PRIVATE_RUNNER": "true"} + env.pop("AUTHORIZED_CONTRACT_SHA", None) + env.update(overrides) + result = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", script], + cwd=root, env=env, capture_output=True, text=True) + self.assertEqual(result.returncode == 0, succeeds, result.stderr) + self.assertEqual(f"contract_sha={sha}\n" in output.read_text(), succeeds) + def test_only_reviewed_main_ancestry_is_authorized(self): with tempfile.TemporaryDirectory() as directory: repository = pathlib.Path(directory) @@ -71,8 +126,14 @@ def assert_provenance(content: str, first_job: str) -> None: raise AssertionError(f"missing provenance control {required!r}") if content.index("authorize-contract:") > content.index(first_job): raise AssertionError("privileged job appears before contract authorization") - if content.count("needs: authorize-contract") < content.count("ref: ${{ inputs.contract_sha }}"): + if content.count("needs: authorize-contract") < content.count("ref: ${{ needs.authorize-contract.outputs.contract_sha }}"): raise AssertionError("a contract checkout skipped authorization") + if 'ref: ${{ inputs.contract_sha }}' in content or 'ref: "${{ inputs.contract_sha }}"' in content: + raise AssertionError("contract checkout bypasses the authorized output") + for job in re.split(r"\n (?=[a-zA-Z0-9_-]+:\n)", content): + if "runs-on: [self-hosted," in job: + if "needs.authorize-contract.outputs.private_runner_allowed == 'true'" not in job: + raise AssertionError("private runner is not guarded by caller authorization") def test_every_entrypoint_authorizes_the_contract(self): for name, first_job in self.WORKFLOWS.items():