diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e5b7acc..03887b4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,6 +27,7 @@ jobs: uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2 with: tofu_version: 1.12.6 + tofu_wrapper: false # The YAML parser test passes an expression through console stdin. - name: Validate repository run: scripts/test.sh diff --git a/.github/workflows/cloudflare-connection.yaml b/.github/workflows/cloudflare-connection.yaml new file mode 100644 index 0000000..def6ff5 --- /dev/null +++ b/.github/workflows/cloudflare-connection.yaml @@ -0,0 +1,38 @@ +name: Cloudflare connection (read-only) + +"on": + workflow_dispatch: + inputs: + confirmation: + description: Type VERIFY_READ_ONLY to check the existing CI connection + required: true + type: string + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - name: Require main and typed confirmation + env: + CONFIRMATION: ${{ inputs.confirmation }} + run: | + if [ "$GITHUB_EVENT_NAME" != workflow_dispatch ] || [ "$GITHUB_REF" != refs/heads/main ] || [ "$CONFIRMATION" != VERIFY_READ_ONLY ]; then + echo 'Read-only connection check refused: use main and the exact confirmation.' >&2 + exit 2 + fi + + - name: Check out the confirmed revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Verify existing Cloudflare connection + env: + CONFIRMATION: ${{ inputs.confirmation }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: bash scripts/check-cloudflare-connection.sh diff --git a/docs/runbooks/bootstrap-and-recovery.md b/docs/runbooks/bootstrap-and-recovery.md index 27c7b95..af0f932 100644 --- a/docs/runbooks/bootstrap-and-recovery.md +++ b/docs/runbooks/bootstrap-and-recovery.md @@ -6,6 +6,18 @@ writes credentials or state into this repository. ## Prerequisites +The manual **Cloudflare connection (read-only)** workflow can check the existing +`CLOUDFLARE_API_TOKEN` CI binding before another credential handoff is requested. +Run it from `main` with confirmation `VERIFY_READ_ONLY`. Its guard runs before +checkout and credential use; it makes only fixed GET requests for the owned zone +and active account token, and prints capability verdicts without account data. +Missing or unverifiable access fails the check. Verified read access does not +establish state custody, import coverage or write permissions. +The workflow never imports, plans, applies or changes provider resources. + +This follows GitHub's [manual workflow controls](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/manually-run-a-workflow) +and [environment-variable input handling](https://docs.github.com/en/actions/reference/security/secure-use#use-an-intermediate-environment-variable). + - OpenTofu 1.12.x and `jq`. - A short-lived Cloudflare bootstrap token with only the permissions needed to read and import the inventoried resources. diff --git a/scripts/check-cloudflare-connection.sh b/scripts/check-cloudflare-connection.sh new file mode 100755 index 0000000..dea3959 --- /dev/null +++ b/scripts/check-cloudflare-connection.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Read-only connection check. Provider responses and credentials never reach logs. +set +x +set -euo pipefail + +stop() { + printf 'CF_CONNECTION=%s\nDEPLOYMENT_READINESS=UNVERIFIED\n' "$1" >&2 + exit 2 +} + +[[ ${GITHUB_EVENT_NAME:-} == workflow_dispatch && ${GITHUB_REF:-} == refs/heads/main && ${CONFIRMATION:-} == VERIFY_READ_ONLY ]] || stop DISPATCH_REFUSED +if ! command -v jq >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1; then + stop TOOLS_UNAVAILABLE +fi +[[ -n ${CLOUDFLARE_API_TOKEN:-} ]] || stop MISSING_CREDENTIAL +[[ ${#CLOUDFLARE_API_TOKEN} -ge 20 && ${#CLOUDFLARE_API_TOKEN} -le 512 && $CLOUDFLARE_API_TOKEN =~ ^[A-Za-z0-9._~+/-]+=*$ ]] || stop INVALID_CREDENTIAL_FORMAT + +read_cloudflare() { + # The credential goes through stdin, never a process argument. Disabling + # curlrc, proxies and redirects keeps the fixed provider destination intact. + printf 'header = "Authorization: Bearer %s"\n' "$CLOUDFLARE_API_TOKEN" | + curl --disable --config - --request GET --proto '=https' --noproxy '*' \ + --max-redirs 0 --connect-timeout 10 --max-time 20 --max-filesize 1048576 \ + --silent --show-error --fail-with-body --url "$1" 2>/dev/null +} + +zone_response=$(read_cloudflare 'https://api.cloudflare.com/client/v4/zones?name=devantler.tech&per_page=50') || stop ZONE_READ_UNVERIFIED +account_id=$(printf '%s' "$zone_response" | jq -er ' + if .success == true and (.result|type) == "array" and (.result|length) == 1 + and .result_info.total_count == 1 and .result_info.total_pages == 1 + and .result[0].name == "devantler.tech" and .result[0].status == "active" + and (.result[0].account.id|type) == "string" + and (.result[0].account.id|test("^[a-f0-9]{32}$")) + then .result[0].account.id else error("owned zone binding unavailable") end +' 2>/dev/null) || stop ZONE_BINDING_UNVERIFIED +unset zone_response + +token_response=$(read_cloudflare "https://api.cloudflare.com/client/v4/accounts/$account_id/tokens/verify") || stop TOKEN_READ_UNVERIFIED +printf '%s' "$token_response" | jq -e ' + .success == true and .result.status == "active" + and (.result.id|type) == "string" and (.result.id|length) > 0 and (.result.id|length) <= 32 +' >/dev/null 2>&1 || stop TOKEN_STATUS_UNVERIFIED +unset token_response account_id +printf '%s\n' 'CF_CONNECTION=READ_ACCESS_VERIFIED' 'DEPLOYMENT_READINESS=UNVERIFIED' diff --git a/scripts/test.sh b/scripts/test.sh index 947ad25..255cae1 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -6,6 +6,7 @@ cd "$repo_root" shellcheck scripts/*.sh scripts/tests/*.sh scripts/tests/test-no-destructive-plan.sh +bash scripts/tests/test-cloudflare-connection.sh tofu fmt -check -recursive tofu init -backend=false -input=false tofu validate diff --git a/scripts/tests/test-cloudflare-connection.sh b/scripts/tests/test-cloudflare-connection.sh new file mode 100755 index 0000000..0f8bbeb --- /dev/null +++ b/scripts/tests/test-cloudflare-connection.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd) +work_dir=$(mktemp -d) +trap 'rm -rf "$work_dir"' EXIT +mkdir -p "$work_dir/bin" "$work_dir/parse" +probe="$repo_root/scripts/check-cloudflare-connection.sh" +fixture_token=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + +cat > "$work_dir/bin/curl" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +printf 'attempt\n' >> "$CF_FIXTURE_ATTEMPTS_FILE" +[[ $1 == --disable ]] || exit 90 +url='' +while [[ $# -gt 0 ]]; do + case $1 in + --disable|--silent|--show-error|--fail-with-body) shift ;; + --config) [[ $2 == - ]] || exit 91; shift 2 ;; + --request) [[ $2 == GET ]] || exit 92; shift 2 ;; + --proto) [[ $2 == '=https' ]] || exit 93; shift 2 ;; + --noproxy) [[ $2 == '*' ]] || exit 94; shift 2 ;; + --max-redirs) [[ $2 == 0 ]] || exit 95; shift 2 ;; + --connect-timeout|--max-time|--max-filesize) shift 2 ;; + --url) url=$2; shift 2 ;; + *) exit 96 ;; + esac +done +IFS= read -r header +[[ $header == 'header = "Authorization: Bearer AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"' ]] || exit 97 +printf '%s\n' "$url" >> "$CF_FIXTURE_CALLS_FILE" +if [[ $CF_FIXTURE_CASE == http-error ]]; then + printf '{"success":false,"errors":[{"code":10000,"message":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}]}' + exit 22 +fi +if [[ $CF_FIXTURE_CASE == invalid-json ]]; then + printf 'not JSON' + exit 0 +fi +case $url in + 'https://api.cloudflare.com/client/v4/zones?name=devantler.tech&per_page=50') + case $CF_FIXTURE_CASE in + ambiguous) printf '{"success":true,"result":[{},{}],"result_info":{"total_count":2,"total_pages":1}}' ;; + partial-page) printf '{"success":true,"result":[{"name":"devantler.tech","status":"active","account":{"id":"22222222222222222222222222222222"}}],"result_info":{"total_count":2,"total_pages":2}}' ;; + wrong-zone) printf '{"success":true,"result":[{"name":"other.test","status":"active","account":{"id":"22222222222222222222222222222222"}}],"result_info":{"total_count":1,"total_pages":1}}' ;; + invalid-account) printf '{"success":true,"result":[{"name":"devantler.tech","status":"active","account":{"id":"../other"}}],"result_info":{"total_count":1,"total_pages":1}}' ;; + *) printf '{"success":true,"result":[{"name":"devantler.tech","status":"active","account":{"id":"22222222222222222222222222222222"}}],"result_info":{"total_count":1,"total_pages":1}}' ;; + esac + ;; + 'https://api.cloudflare.com/client/v4/accounts/22222222222222222222222222222222/tokens/verify') + case $CF_FIXTURE_CASE in + inactive) printf '{"success":true,"result":{"id":"33333333333333333333333333333333","status":"disabled"}}' ;; + short-token-id) printf '{"success":true,"result":{"id":"3333","status":"active"}}' ;; + *) printf '{"success":true,"result":{"id":"33333333333333333333333333333333","status":"active"}}' ;; + esac + ;; + *) exit 98 ;; +esac +STUB +chmod +x "$work_dir/bin/curl" + +run_probe() { + local scenario=$1 expected_rc=$2 expected_calls=$3 token=$fixture_token + local ref=refs/heads/main event=workflow_dispatch confirmation=VERIFY_READ_ONLY probe_rc=0 + local probe_path="$work_dir/bin:$PATH" + case $scenario in + absent-token) token='' ;; + wrong-ref) ref=refs/heads/unreviewed ;; + wrong-event) event=pull_request ;; + wrong-confirmation) confirmation='VERIFY_READ_ONLY"; exit 0; #' ;; + missing-tool) probe_path="$work_dir/bin" ;; + esac + : > "$work_dir/calls" + : > "$work_dir/attempts" + if [[ $scenario == inherited-trace ]]; then + set -- /bin/bash -x "$probe" + else + set -- /bin/bash "$probe" + fi + env PATH="$probe_path" CF_FIXTURE_CASE="$scenario" CF_FIXTURE_CALLS_FILE="$work_dir/calls" CF_FIXTURE_ATTEMPTS_FILE="$work_dir/attempts" \ + CLOUDFLARE_API_TOKEN="$token" GITHUB_REF="$ref" GITHUB_EVENT_NAME="$event" \ + CONFIRMATION="$confirmation" "$@" > "$work_dir/$scenario.out" 2>&1 || probe_rc=$? + [[ $probe_rc == "$expected_rc" ]] || { echo "FAIL $scenario: expected exit $expected_rc, got $probe_rc" >&2; exit 1; } + [[ $(wc -l < "$work_dir/calls" | tr -d ' ') == "$expected_calls" ]] || { echo "FAIL $scenario: unexpected provider request count" >&2; exit 1; } + [[ $(wc -l < "$work_dir/attempts" | tr -d ' ') == "$expected_calls" ]] || { echo "FAIL $scenario: an unsafe request was attempted" >&2; exit 1; } + if grep -Eq 'AAAAAAAAAAAA|222222222222|333333333333|Authorization:' "$work_dir/$scenario.out"; then + echo "FAIL $scenario: private request material reached output" >&2 + exit 1 + fi +} + +run_probe success 0 2 +run_probe inherited-trace 0 2 +run_probe short-token-id 0 2 +grep -Fq 'CF_CONNECTION=READ_ACCESS_VERIFIED' "$work_dir/success.out" +grep -Fq 'DEPLOYMENT_READINESS=UNVERIFIED' "$work_dir/success.out" +run_probe absent-token 2 0 +run_probe wrong-ref 2 0 +run_probe wrong-event 2 0 +run_probe wrong-confirmation 2 0 +run_probe missing-tool 2 0 +run_probe http-error 2 1 +run_probe invalid-json 2 1 +run_probe ambiguous 2 1 +run_probe partial-page 2 1 +run_probe wrong-zone 2 1 +run_probe invalid-account 2 1 +run_probe inactive 2 2 + +# Reuse the project's required OpenTofu runtime as a real YAML parser; this +# empty, disposable module has no provider, backend or production credential. +workflow="$repo_root/.github/workflows/cloudflare-connection.yaml" +expression=$(jq -nr --arg path "$workflow" '"jsonencode(yamldecode(file(" + ($path|tojson) + ")))"') +printf '%s\n' "$expression" | tofu -chdir="$work_dir/parse" console -no-color > "$work_dir/workflow-string.json" +jq -r . "$work_dir/workflow-string.json" > "$work_dir/workflow.json" + +check_workflow() { + jq -e ' + .permissions == {contents:"read"} + and (.on|keys) == ["workflow_dispatch"] + and .on.workflow_dispatch.inputs.confirmation.type == "string" + and .on.workflow_dispatch.inputs.confirmation.required == true + and (.jobs|keys) == ["check"] + and .jobs.check["runs-on"] == "ubuntu-latest" + and .jobs.check["timeout-minutes"] == 3 + and (.jobs.check.permissions // {}) == {} + and (.jobs.check|has("continue-on-error")|not) + and (.jobs.check|has("if")|not) + and all(.jobs.check.steps[]; (has("continue-on-error")|not) and (has("if")|not)) + and (.env // {}) == {} and (.jobs.check.env // {}) == {} + and (.defaults // {}) == {} and (.jobs.check.defaults // {}) == {} + and (.jobs.check.steps|length) == 3 + and .jobs.check.steps[0].env == {CONFIRMATION:"${{ inputs.confirmation }}"} + and (.jobs.check.steps[0].run|contains("${{")|not) + and .jobs.check.steps[1].uses == "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1" + and .jobs.check.steps[1].with == {"persist-credentials":false,ref:"${{ github.sha }}"} + and (.jobs.check.steps[1].env // {}) == {} + and .jobs.check.steps[2].env == {CONFIRMATION:"${{ inputs.confirmation }}",CLOUDFLARE_API_TOKEN:"${{ secrets.CLOUDFLARE_API_TOKEN }}"} + and .jobs.check.steps[2].run == "bash scripts/check-cloudflare-connection.sh" + ' "$1" >/dev/null +} +check_workflow "$work_dir/workflow.json" +jq -r '.jobs.check.steps[0].run' "$work_dir/workflow.json" > "$work_dir/guard.sh" +env GITHUB_REF=refs/heads/main GITHUB_EVENT_NAME=workflow_dispatch CONFIRMATION=VERIFY_READ_ONLY bash "$work_dir/guard.sh" +for scenario in wrong-ref wrong-event wrong-confirmation; do + ref=refs/heads/main; event=workflow_dispatch; confirmation=VERIFY_READ_ONLY + case $scenario in + wrong-ref) ref=refs/heads/unreviewed ;; + wrong-event) event=pull_request ;; + wrong-confirmation) confirmation='VERIFY_READ_ONLY"; exit 0; #' ;; + esac + if env GITHUB_REF="$ref" GITHUB_EVENT_NAME="$event" CONFIRMATION="$confirmation" bash "$work_dir/guard.sh" > /dev/null 2>&1; then + echo "FAIL inline guard accepted $scenario" >&2; exit 1 + fi +done +for mutation in '.permissions.contents="write"' '.on.push={}' '.jobs.check.steps|=.[1:]' '.jobs.check.steps[0].env.CLOUDFLARE_API_TOKEN="exposed-before-guard"' '.jobs.check.steps[1].with.token="provider-key"'; do + jq "$mutation" "$work_dir/workflow.json" > "$work_dir/mutant.json" + if check_workflow "$work_dir/mutant.json"; then + echo 'FAIL unsafe workflow control accepted' >&2; exit 1 + fi +done +printf '%s\n' 'PASS: read-only Cloudflare connection and workflow failure controls'