diff --git a/.github/workflows/cd-apply.yml b/.github/workflows/cd-apply.yml index a76ea61..cdb978c 100644 --- a/.github/workflows/cd-apply.yml +++ b/.github/workflows/cd-apply.yml @@ -9,13 +9,40 @@ on: state_path: {type: string, required: true} approval_path: {type: string, required: true} -permissions: - contents: read - id-token: write +permissions: {} jobs: + authorize-contract: + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 5 + outputs: + contract_sha: ${{ steps.authorize.outputs.contract_sha }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: NDDev-OpenNetwork/cd-workflows + ref: main + fetch-depth: 0 + persist-credentials: false + path: authority + - name: Authorize reviewed contract ancestry + id: authorize + env: + CONTRACT_SHA: ${{ inputs.contract_sha }} + run: | + [[ "$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" apply: + needs: authorize-contract runs-on: [self-hosted, cd-apply-out-of-band] + permissions: + contents: read + id-token: write environment: cd-apply concurrency: group: cd-apply-${{ inputs.deployment_id }} @@ -27,11 +54,13 @@ 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: | [[ "$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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7010bf4..6a7fdb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ Versioning. ### Fixed +- `cd-apply` now proves a caller-supplied contract commit is an ancestor of + reviewed `main` in a GitHub-hosted authorization job before the privileged + self-hosted/OIDC job can be scheduled. + - Every `actions/checkout` pin carried the comment `# v5.0.0` while its SHA was `v7.0.1` — two majors apart, in the module that deploys the fleet. Fifteen lines, wrong from the day they were written, because nothing compared the diff --git a/README.md b/README.md index 88f531a..d37fe83 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,10 @@ 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 +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. ## Trust boundaries diff --git a/scripts/validate_module.sh b/scripts/validate_module.sh index 558a45e..bee1bdb 100755 --- a/scripts/validate_module.sh +++ b/scripts/validate_module.sh @@ -6,7 +6,7 @@ cd "${repo_root}" python3 scripts/cd_contract.py validate-schema python3 -m unittest discover -s tests -v -python3 -m py_compile scripts/cd_contract.py tests/test_cd_contract.py +python3 -m py_compile scripts/cd_contract.py tests/test_cd_contract.py tests/test_contract_sha_provenance.py python3 - <<'PY' import json @@ -70,6 +70,21 @@ for name in ("apply", "verify", "resume", "rollback", "evidence"): for required in ("runs-on: [self-hosted, cd-apply-out-of-band]", "environment: cd-apply", "cancel-in-progress: false", "id-token: write"): if required not in content: raise SystemExit(f"cd-{name} workflow lacks {required!r}") + if name == "apply": + for required in ( + "authorize-contract:", + "runs-on: ubuntu-latest", + "permissions: {}", + "fetch-depth: 0", + 'git -C authority fetch --no-tags --depth=1 origin "$CONTRACT_SHA"', + 'git -C authority merge-base --is-ancestor "$CONTRACT_SHA" refs/remotes/origin/main', + "needs: authorize-contract", + '[[ "$CONTRACT_SHA" == "$AUTHORIZED_CONTRACT_SHA" ]]', + ): + if required not in content: + raise SystemExit(f"cd-apply workflow lacks provenance control {required!r}") + if content.index("authorize-contract:") > content.index(" apply:"): + raise SystemExit("cd-apply privileged job appears before contract 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 new file mode 100644 index 0000000..03195b0 --- /dev/null +++ b/tests/test_contract_sha_provenance.py @@ -0,0 +1,44 @@ +import pathlib +import subprocess +import tempfile +import unittest + + +class ContractSHAProvenanceTests(unittest.TestCase): + def test_only_reviewed_main_ancestry_is_authorized(self): + with tempfile.TemporaryDirectory() as directory: + repository = pathlib.Path(directory) + self.git(repository, "init", "-b", "main") + self.git(repository, "config", "user.name", "Example") + self.git(repository, "config", "user.email", "example@example.invalid") + reviewed_parent = self.commit(repository, "reviewed-parent") + reviewed_head = self.commit(repository, "reviewed-head") + self.git(repository, "checkout", "-b", "fork-like", reviewed_parent) + unreviewed = self.commit(repository, "unreviewed") + + self.assertTrue(self.is_ancestor(repository, reviewed_parent, reviewed_head)) + self.assertTrue(self.is_ancestor(repository, reviewed_head, reviewed_head)) + self.assertFalse(self.is_ancestor(repository, unreviewed, reviewed_head)) + + @staticmethod + def git(repository: pathlib.Path, *arguments: str) -> str: + return subprocess.check_output( + ["git", "-C", str(repository), *arguments], text=True, stderr=subprocess.DEVNULL + ).strip() + + def commit(self, repository: pathlib.Path, content: str) -> str: + (repository / "contract.txt").write_text(content, encoding="utf-8") + self.git(repository, "add", "contract.txt") + self.git(repository, "commit", "-m", content) + return self.git(repository, "rev-parse", "HEAD") + + @staticmethod + def is_ancestor(repository: pathlib.Path, candidate: str, reviewed_head: str) -> bool: + return subprocess.run( + ["git", "-C", str(repository), "merge-base", "--is-ancestor", candidate, reviewed_head], + check=False, + ).returncode == 0 + + +if __name__ == "__main__": + unittest.main()