diff --git a/.github/workflows/cd-evidence.yml b/.github/workflows/cd-evidence.yml index be7285e..443d31f 100644 --- a/.github/workflows/cd-evidence.yml +++ b/.github/workflows/cd-evidence.yml @@ -8,18 +8,47 @@ on: state_path: {type: string, required: true} evidence_path: {type: string, required: true} -permissions: - contents: read +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}$ ]] + [[ "$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" + validate: + needs: authorize-contract + permissions: + contents: read runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: {persist-credentials: false, path: source} - name: Validate immutable inputs before contract checkout - env: {CONTRACT_SHA: "${{ inputs.contract_sha }}", PLAN_PATH: "${{ inputs.plan_path }}", STATE_PATH: "${{ inputs.state_path }}", EVIDENCE_PATH: "${{ inputs.evidence_path }}"} + 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: | [[ "$CONTRACT_SHA" =~ ^[0-9a-f]{40}$ ]] for value in "$PLAN_PATH" "$STATE_PATH" "$EVIDENCE_PATH"; do diff --git a/.github/workflows/cd-plan.yml b/.github/workflows/cd-plan.yml index 4d1856a..9579903 100644 --- a/.github/workflows/cd-plan.yml +++ b/.github/workflows/cd-plan.yml @@ -20,12 +20,41 @@ on: description: Digest of the sealed immutable plan. value: ${{ jobs.hosted.outputs.plan_digest || jobs.out-of-band.outputs.plan_digest }} -permissions: - contents: read +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}$ ]] + [[ "$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" + hosted: if: ${{ inputs.execution_surface == 'hosted' }} + needs: authorize-contract + permissions: + contents: read runs-on: ubuntu-latest timeout-minutes: 5 outputs: @@ -34,10 +63,12 @@ jobs: - name: Validate immutable inputs env: CONTRACT_SHA: ${{ inputs.contract_sha }} + AUTHORIZED_CONTRACT_SHA: ${{ needs.authorize-contract.outputs.contract_sha }} PLAN_PATH: ${{ inputs.plan_draft_path }} run: | set -euo pipefail [[ "$CONTRACT_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$CONTRACT_SHA" == "$AUTHORIZED_CONTRACT_SHA" ]] [[ "$PLAN_PATH" =~ ^[A-Za-z0-9._/-]+$ && "$PLAN_PATH" != /* && "$PLAN_PATH" != *..* ]] - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -68,6 +99,9 @@ jobs: out-of-band: if: ${{ inputs.execution_surface == 'out-of-band' }} + needs: authorize-contract + permissions: + contents: read runs-on: [self-hosted, cd-plan-out-of-band] timeout-minutes: 5 outputs: @@ -76,10 +110,12 @@ jobs: - name: Validate immutable inputs env: CONTRACT_SHA: ${{ inputs.contract_sha }} + AUTHORIZED_CONTRACT_SHA: ${{ needs.authorize-contract.outputs.contract_sha }} PLAN_PATH: ${{ inputs.plan_draft_path }} run: | set -euo pipefail [[ "$CONTRACT_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$CONTRACT_SHA" == "$AUTHORIZED_CONTRACT_SHA" ]] [[ "$PLAN_PATH" =~ ^[A-Za-z0-9._/-]+$ && "$PLAN_PATH" != /* && "$PLAN_PATH" != *..* ]] - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/cd-resume.yml b/.github/workflows/cd-resume.yml index bf38ee4..015e547 100644 --- a/.github/workflows/cd-resume.yml +++ b/.github/workflows/cd-resume.yml @@ -9,12 +9,41 @@ 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}$ ]] + [[ "$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" + resume: + needs: authorize-contract + permissions: + contents: read + id-token: write runs-on: [self-hosted, cd-apply-out-of-band] environment: cd-apply concurrency: @@ -25,7 +54,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: {persist-credentials: false, path: source} - name: Validate immutable inputs before contract checkout - env: {CONTRACT_SHA: "${{ inputs.contract_sha }}", PLAN_PATH: "${{ inputs.plan_path }}", STATE_PATH: "${{ inputs.state_path }}", APPROVAL_PATH: "${{ inputs.approval_path }}"} + 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}$ ]] for value in "$PLAN_PATH" "$STATE_PATH" "$APPROVAL_PATH"; do diff --git a/.github/workflows/cd-rollback.yml b/.github/workflows/cd-rollback.yml index 438b2c3..76546ef 100644 --- a/.github/workflows/cd-rollback.yml +++ b/.github/workflows/cd-rollback.yml @@ -9,12 +9,41 @@ 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}$ ]] + [[ "$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" + rollback: + needs: authorize-contract + permissions: + contents: read + id-token: write runs-on: [self-hosted, cd-apply-out-of-band] environment: cd-apply concurrency: @@ -25,7 +54,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: {persist-credentials: false, path: source} - name: Validate immutable inputs before contract checkout - env: {CONTRACT_SHA: "${{ inputs.contract_sha }}", PLAN_PATH: "${{ inputs.plan_path }}", STATE_PATH: "${{ inputs.state_path }}", APPROVAL_PATH: "${{ inputs.approval_path }}"} + 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}$ ]] for value in "$PLAN_PATH" "$STATE_PATH" "$APPROVAL_PATH"; do diff --git a/.github/workflows/cd-verify.yml b/.github/workflows/cd-verify.yml index 7df76e7..4d61666 100644 --- a/.github/workflows/cd-verify.yml +++ b/.github/workflows/cd-verify.yml @@ -8,11 +8,40 @@ on: plan_path: {type: string, required: true} state_path: {type: string, required: true} -permissions: - contents: read +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}$ ]] + [[ "$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" + verify: + needs: authorize-contract + permissions: + contents: read runs-on: [self-hosted, cd-verify-out-of-band] concurrency: group: cd-verify-${{ inputs.deployment_id }} @@ -22,7 +51,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: {persist-credentials: false, path: source} - name: Validate immutable inputs before contract checkout - env: {CONTRACT_SHA: "${{ inputs.contract_sha }}", PLAN_PATH: "${{ inputs.plan_path }}", STATE_PATH: "${{ inputs.state_path }}"} + 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: | [[ "$CONTRACT_SHA" =~ ^[0-9a-f]{40}$ ]] for value in "$PLAN_PATH" "$STATE_PATH"; do diff --git a/scripts/validate_module.sh b/scripts/validate_module.sh index bee1bdb..6162378 100755 --- a/scripts/validate_module.sh +++ b/scripts/validate_module.sh @@ -62,7 +62,11 @@ for forbidden in ("eval ", "bash -c", "ssh ", "sudo ", "curl "): if forbidden in lifecycle_action: raise SystemExit(f"lifecycle action exposes forbidden execution surface {forbidden!r}") -for name in ("apply", "verify", "resume", "rollback", "evidence"): +first_job = { + "apply": " apply:", "verify": " verify:", "resume": " resume:", + "rollback": " rollback:", "evidence": " validate:", "plan": " hosted:", +} +for name in ("apply", "verify", "resume", "rollback", "evidence", "plan"): content = Path(f".github/workflows/cd-{name}.yml").read_text(encoding="utf-8") if "pull_request_target" in content or "secrets:" in content or "runs-on: ${{" in content: raise SystemExit(f"cd-{name} workflow violates the fixed trust boundary") @@ -70,21 +74,27 @@ 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") + # Every entrypoint that accepts a contract_sha proves reviewed ancestry on + # a hosted job before anything checks that commit out, and the consuming + # job re-compares the authorized value. Only cd-apply carried this once; + # resume, rollback, verify, evidence and the out-of-band plan branch + # executed a caller-supplied commit on their runners without it. + 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-{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 }}"): + 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 03195b0..6377d85 100644 --- a/tests/test_contract_sha_provenance.py +++ b/tests/test_contract_sha_provenance.py @@ -42,3 +42,52 @@ def is_ancestor(repository: pathlib.Path, candidate: str, reviewed_head: str) -> if __name__ == "__main__": unittest.main() + + +class WorkflowProvenanceStructureTests(unittest.TestCase): + """Every entrypoint that accepts a contract_sha must authorize it on a + hosted job before any job checks that commit out, and the consuming job + must re-compare the authorized value. cd-apply carried this alone once; + the others executed a caller-supplied commit on privileged runners.""" + + WORKFLOWS = { + "cd-apply.yml": " apply:", + "cd-verify.yml": " verify:", + "cd-resume.yml": " resume:", + "cd-rollback.yml": " rollback:", + "cd-evidence.yml": " validate:", + "cd-plan.yml": " hosted:", + } + + @staticmethod + def assert_provenance(content: str, first_job: str) -> None: + for required in ( + "authorize-contract:", + '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 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 }}"): + raise AssertionError("a contract checkout skipped authorization") + + def test_every_entrypoint_authorizes_the_contract(self): + for name, first_job in self.WORKFLOWS.items(): + content = pathlib.Path(".github/workflows", name).read_text(encoding="utf-8") + with self.subTest(workflow=name): + self.assert_provenance(content, first_job) + + def test_checker_rejects_a_job_that_skips_authorization(self): + content = pathlib.Path(".github/workflows/cd-resume.yml").read_text(encoding="utf-8") + stripped = content.replace(" needs: authorize-contract\n", "", 1) + with self.assertRaises(AssertionError): + self.assert_provenance(stripped, " resume:") + + def test_checker_rejects_a_missing_comparison(self): + content = pathlib.Path(".github/workflows/cd-rollback.yml").read_text(encoding="utf-8") + stripped = content.replace(' [[ "$CONTRACT_SHA" == "$AUTHORIZED_CONTRACT_SHA" ]]\n', "", 1) + with self.assertRaises(AssertionError): + self.assert_provenance(stripped, " rollback:")