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-evidence.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 38 additions & 2 deletions .github/workflows/cd-plan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
37 changes: 33 additions & 4 deletions .github/workflows/cd-resume.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
37 changes: 33 additions & 4 deletions .github/workflows/cd-rollback.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
35 changes: 32 additions & 3 deletions .github/workflows/cd-verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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
Expand Down
42 changes: 26 additions & 16 deletions scripts/validate_module.sh
Original file line number Diff line number Diff line change
Expand Up @@ -62,29 +62,39 @@ 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")
if name in {"apply", "resume", "rollback"}:
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")

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