Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
35 changes: 32 additions & 3 deletions .github/workflows/cd-apply.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 16 additions & 1 deletion scripts/validate_module.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")

Expand Down
44 changes: 44 additions & 0 deletions tests/test_contract_sha_provenance.py
Original file line number Diff line number Diff line change
@@ -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()