From 4f304404a61bf04ab7e45f57427f0abfd35a17e5 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 26 Oct 2026 07:06:39 +0100 Subject: [PATCH 1/2] feat: prepare encrypted read-only DNS snapshots --- .github/dns-recovery-recipient.txt | 1 + .github/workflows/ci.yaml | 7 ++ .github/workflows/dns-snapshot.yaml | 62 ++++++++++ README.md | 4 +- docs/runbooks/bootstrap-and-recovery.md | 31 +++++ scripts/export-dns-snapshot.sh | 83 +++++++++++++ scripts/test.sh | 1 + scripts/tests/test-dns-snapshot.sh | 155 ++++++++++++++++++++++++ 8 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 .github/dns-recovery-recipient.txt create mode 100644 .github/workflows/dns-snapshot.yaml create mode 100644 scripts/export-dns-snapshot.sh create mode 100644 scripts/tests/test-dns-snapshot.sh diff --git a/.github/dns-recovery-recipient.txt b/.github/dns-recovery-recipient.txt new file mode 100644 index 0000000..f945724 --- /dev/null +++ b/.github/dns-recovery-recipient.txt @@ -0,0 +1 @@ +age1upjezmgf4n246gnnwx76gt2fe8e42pnnx2x7kkddzdgf0ja3pcqqr6q6ln diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 03887b4..15e6362 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -29,6 +29,13 @@ jobs: tofu_version: 1.12.6 tofu_wrapper: false # The YAML parser test passes an expression through console stdin. + - name: Install the pinned encryption tool for native snapshot tests + run: | + curl --disable --fail --silent --show-error --proto '=https' --location --max-redirs 3 --connect-timeout 10 --max-time 60 --output "$RUNNER_TEMP/age.tar.gz" https://github.com/FiloSottile/age/releases/download/v1.3.2/age-v1.3.2-linux-amd64.tar.gz + printf '%s %s\n' cbe24006683f8eb669266162894b9a522a1af52f2665fbc63a4bb032ed26ac10 "$RUNNER_TEMP/age.tar.gz" | sha256sum --check --status + tar -xzf "$RUNNER_TEMP/age.tar.gz" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/age" >> "$GITHUB_PATH" + - name: Validate repository run: scripts/test.sh diff --git a/.github/workflows/dns-snapshot.yaml b/.github/workflows/dns-snapshot.yaml new file mode 100644 index 0000000..afe9074 --- /dev/null +++ b/.github/workflows/dns-snapshot.yaml @@ -0,0 +1,62 @@ +name: DNS snapshot (encrypted, read-only) + +"on": + workflow_dispatch: + inputs: + confirmation: + description: Type EXPORT_ENCRYPTED_DNS after approving the encrypted artifact transport + required: true + type: string + +permissions: + contents: read + +concurrency: + group: encrypted-dns-snapshot + cancel-in-progress: false + +jobs: + snapshot: + runs-on: ubuntu-latest + timeout-minutes: 8 + steps: + - name: Require the approved operator, main and explicit export + env: + CONFIRMATION: ${{ inputs.confirmation }} + run: | + if [ "$GITHUB_EVENT_NAME" != workflow_dispatch ] || [ "$GITHUB_REF" != refs/heads/main ] || [ "$GITHUB_REPOSITORY" != devantler-tech/cloudflare ] || [ "$GITHUB_ACTOR" != devantler ] || [ "$GITHUB_RUN_ATTEMPT" != 1 ] || [ "$CONFIRMATION" != EXPORT_ENCRYPTED_DNS ]; then + echo 'Encrypted DNS export refused: use the approved operator, main and exact confirmation on a first attempt.' >&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: Install the pinned encryption tool + run: | + curl --disable --fail --silent --show-error --proto '=https' --location --max-redirs 3 --connect-timeout 10 --max-time 60 --output "$RUNNER_TEMP/age.tar.gz" https://github.com/FiloSottile/age/releases/download/v1.3.2/age-v1.3.2-linux-amd64.tar.gz + printf '%s %s\n' cbe24006683f8eb669266162894b9a522a1af52f2665fbc63a4bb032ed26ac10 "$RUNNER_TEMP/age.tar.gz" | sha256sum --check --status + tar -xzf "$RUNNER_TEMP/age.tar.gz" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/age" >> "$GITHUB_PATH" + + - name: Read, validate and encrypt the owned DNS records + env: + CONFIRMATION: ${{ inputs.confirmation }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + RECIPIENT_FILE: .github/dns-recovery-recipient.txt + OUTPUT_DIR: ${{ runner.temp }}/cloudflare-export + run: bash scripts/export-dns-snapshot.sh + + - name: Export only the encrypted snapshot + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: encrypted-dns-snapshot + path: ${{ runner.temp }}/cloudflare-export/dns-snapshot.json.age + if-no-files-found: error + retention-days: 1 + compression-level: 0 + overwrite: false + include-hidden-files: false diff --git a/README.md b/README.md index 8b801ef..7741de2 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,9 @@ You need: - [OpenTofu](https://opentofu.org/docs/intro/install/) on the 1.12 line (`versions.tf` requires `~> 1.12.0`; CI runs 1.12.6); -- [ShellCheck](https://github.com/koalaman/shellcheck#installing) and `jq`; +- [ShellCheck](https://github.com/koalaman/shellcheck#installing), `jq`, and + [age](https://github.com/FiloSottile/age) (`age` and `age-keygen` on `PATH`; + CI verifies the pinned 1.3.2 release archive before using it); - a POSIX shell; and - network access to `registry.opentofu.org` and to GitHub's release downloads, where OpenTofu fetches the pinned Cloudflare provider on the first run. diff --git a/docs/runbooks/bootstrap-and-recovery.md b/docs/runbooks/bootstrap-and-recovery.md index af0f932..33fe63b 100644 --- a/docs/runbooks/bootstrap-and-recovery.md +++ b/docs/runbooks/bootstrap-and-recovery.md @@ -15,6 +15,37 @@ 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. +### Encrypted DNS inventory + +The **DNS snapshot (encrypted, read-only)** workflow is disabled until a manual +dispatch by the maintainer on `main` with `EXPORT_ENCRYPTED_DNS`. Before its +first real dispatch, obtain explicit approval for the transport: this is a +public repository, so its short-lived Actions artifact is downloadable by +other signed-in GitHub users. Only authenticated age ciphertext is uploaded; +the matching dedicated private identity stays in private operator custody. +The public recipient is reviewed in `.github/dns-recovery-recipient.txt`. +Never dispatch unless that identity is recoverable and the destination is +approved. The workflow rejects reruns; a fresh snapshot needs a fresh dispatch. + +This makes only fixed GET requests for the owned zone and its DNS records. +It verifies pagination counts, zone membership and record IDs, and preserves +all returned record fields. Missing access, partial pages, changing counts, +duplicates or invalid responses fail without an export. Pagination cannot +lock live DNS: retain a quiet change window and compare again before import. +This is a DNS snapshot, not a full account inventory or independent state. + +Download the single `dns-snapshot.json.age` from the completed run into the +approved private recovery folder. Decrypt privately with the dedicated +identity, check the source revision, owned-zone binding and complete records, +then confirm the encrypted recovery copy is readable before deleting the +Actions artifact. Retention is capped at one day; it is transport, never the +recovery store. Do not print plaintext, account details or identity material. +Temporary local custody requires a verified transfer to permanent independent +custody before cleanup; do not delete the only recoverable copy. + +Existing import, state, no-destroy plan and first-apply approval requirements +below remain in effect. A completed snapshot does not enable an apply. + 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). diff --git a/scripts/export-dns-snapshot.sh b/scripts/export-dns-snapshot.sh new file mode 100644 index 0000000..7b3bf53 --- /dev/null +++ b/scripts/export-dns-snapshot.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Private, read-only inventory. Only authenticated ciphertext leaves scratch. +set +x +set -euo pipefail +umask 077 + +stop() { printf 'DNS_SNAPSHOT=%s\n' "$1" >&2; exit 2; } + +[[ ${GITHUB_EVENT_NAME:-} == workflow_dispatch && ${GITHUB_REF:-} == refs/heads/main \ + && ${GITHUB_REPOSITORY:-} == devantler-tech/cloudflare && ${GITHUB_ACTOR:-} == devantler \ + && ${GITHUB_RUN_ATTEMPT:-} == 1 && ${GITHUB_SHA:-} =~ ^[a-f0-9]{40}$ \ + && ${CONFIRMATION:-} == EXPORT_ENCRYPTED_DNS ]] || stop DISPATCH_REFUSED +[[ -n ${CLOUDFLARE_API_TOKEN:-} ]] || stop MISSING_CREDENTIAL +provider_token=$CLOUDFLARE_API_TOKEN +unset CLOUDFLARE_API_TOKEN +[[ ${#provider_token} -ge 20 && ${#provider_token} -le 512 && $provider_token =~ ^[A-Za-z0-9._~+/-]+=*$ ]] || stop INVALID_CREDENTIAL_FORMAT +for tool in jq curl age; do command -v "$tool" >/dev/null 2>&1 || stop TOOLS_UNAVAILABLE; done +[[ -n ${OUTPUT_DIR:-} && $OUTPUT_DIR == /* && ! -e $OUTPUT_DIR && ! -L $OUTPUT_DIR \ + && -f ${RECIPIENT_FILE:-} && ! -L $RECIPIENT_FILE ]] || stop OUTPUT_OR_RECIPIENT_REFUSED +recipient=$(cat "$RECIPIENT_FILE") +[[ $recipient =~ ^age1[023456789acdefghjklmnpqrstuvwxyz]{58}$ ]] || stop RECIPIENT_REFUSED + +scratch=$(mktemp -d) +trap 'rm -rf "$scratch"' EXIT +# Validate the native recipient before contacting the provider. +printf 'recipient validation\n' | age -r "$recipient" -o "$scratch/probe.age" 2>/dev/null || stop RECIPIENT_REFUSED + +read_cloudflare() { + printf 'header = "Authorization: Bearer %s"\n' "$provider_token" | + curl --disable --config - --request GET --proto '=https' --noproxy '*' \ + --max-redirs 0 --connect-timeout 10 --max-time 20 --max-filesize 8388608 \ + --silent --fail --url "$1" 2>/dev/null +} + +read_cloudflare 'https://api.cloudflare.com/client/v4/zones?name=devantler.tech&per_page=50' > "$scratch/zone.json" || stop ZONE_READ_UNVERIFIED +zone_id=$(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].id|type) == "string" and (.result[0].id|test("^[a-f0-9]{32}$")) + and (.result[0].account.id|type) == "string" and (.result[0].account.id|test("^[a-f0-9]{32}$")) + then .result[0].id else error("zone binding unavailable") end +' "$scratch/zone.json" 2>/dev/null) || stop ZONE_BINDING_UNVERIFIED + +page=1 pages=1 total=0 +while ((page <= pages)); do + read_cloudflare "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?per_page=1000&page=$page" > "$scratch/page-$page.json" || stop RECORD_READ_UNVERIFIED + jq -e --argjson page "$page" ' + .success == true and (.result|type) == "array" + and .result_info.page == $page and .result_info.per_page == 1000 + and (.result_info.total_count|type) == "number" + and (.result_info.total_count|floor) == .result_info.total_count + and .result_info.total_count > 0 and .result_info.total_count <= 10000 + and .result_info.total_pages == ((.result_info.total_count / 1000)|ceil) + and .result_info.count == (.result|length) + and (.result|length) == ([1000, (.result_info.total_count - (($page-1)*1000))]|min) + and all(.result[]; + (.id|type) == "string" and (.id|test("^[a-f0-9]{32}$")) + and (.name|type) == "string" and (.name == "devantler.tech" or (.name|endswith(".devantler.tech"))) + and (.type|type) == "string" and (.type|length) > 0) + ' "$scratch/page-$page.json" >/dev/null 2>&1 || stop PAGINATION_UNVERIFIED + if ((page == 1)); then + pages=$(jq -r .result_info.total_pages "$scratch/page-$page.json") + total=$(jq -r .result_info.total_count "$scratch/page-$page.json") + else + jq -e --argjson pages "$pages" --argjson total "$total" ' + .result_info.total_pages == $pages and .result_info.total_count == $total + ' "$scratch/page-$page.json" >/dev/null 2>&1 || stop INVENTORY_CHANGED + fi + page=$((page+1)) +done +unset provider_token zone_id + +jq -s '[.[].result[]]' "$scratch"/page-*.json > "$scratch/records.json" 2>/dev/null || stop INVENTORY_UNVERIFIED +jq -e --argjson total "$total" 'length == $total and ([.[].id]|unique|length) == $total' "$scratch/records.json" >/dev/null 2>&1 || stop DUPLICATE_OR_MISSING_RECORDS +jq -n --slurpfile zone "$scratch/zone.json" --slurpfile records "$scratch/records.json" \ + --arg revision "$GITHUB_SHA" --arg captured "$(date -u +%Y-%m-%dT%H:%M:%SZ)" ' + {schema:1, source_revision:$revision, captured_at:$captured, zone:$zone[0].result[0], records:$records[0]} +' > "$scratch/snapshot.json" 2>/dev/null || stop INVENTORY_UNVERIFIED +age -r "$recipient" -o "$scratch/snapshot.age" "$scratch/snapshot.json" 2>/dev/null || stop ENCRYPTION_FAILED +mkdir -m 700 "$OUTPUT_DIR" || stop OUTPUT_REFUSED +mv "$scratch/snapshot.age" "$OUTPUT_DIR/dns-snapshot.json.age" +printf '%s\n' 'DNS_SNAPSHOT=ENCRYPTED_COMPLETE' 'DEPLOYMENT_READINESS=UNVERIFIED' diff --git a/scripts/test.sh b/scripts/test.sh index 255cae1..758da32 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -7,6 +7,7 @@ cd "$repo_root" shellcheck scripts/*.sh scripts/tests/*.sh scripts/tests/test-no-destructive-plan.sh bash scripts/tests/test-cloudflare-connection.sh +bash scripts/tests/test-dns-snapshot.sh tofu fmt -check -recursive tofu init -backend=false -input=false tofu validate diff --git a/scripts/tests/test-dns-snapshot.sh b/scripts/tests/test-dns-snapshot.sh new file mode 100644 index 0000000..991b79f --- /dev/null +++ b/scripts/tests/test-dns-snapshot.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd) +scratch=$(mktemp -d) +trap 'rm -rf "$scratch"' EXIT +mkdir "$scratch/bin" "$scratch/parse" +age-keygen -o "$scratch/key" 2>/dev/null +age-keygen -y "$scratch/key" > "$scratch/recipient" +token=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + +cat > "$scratch/bin/curl" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +[[ -z ${CLOUDFLARE_API_TOKEN:-} ]] || exit 80 +[[ $1 == --disable ]] || exit 81 +url='' +while [[ $# -gt 0 ]]; do + case $1 in + --disable|--silent|--fail) shift ;; + --config) [[ $2 == - ]] || exit 82; shift 2 ;; + --request) [[ $2 == GET ]] || exit 83; shift 2 ;; + --proto) [[ $2 == '=https' ]] || exit 84; shift 2 ;; + --noproxy) [[ $2 == '*' ]] || exit 85; shift 2 ;; + --max-redirs) [[ $2 == 0 ]] || exit 86; shift 2 ;; + --connect-timeout|--max-time|--max-filesize) shift 2 ;; + --url) url=$2; shift 2 ;; + *) exit 87 ;; + esac +done +IFS= read -r header +[[ $header == 'header = "Authorization: Bearer AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"' ]] || exit 88 +printf '%s\n' "$url" >> "$FIXTURE_DIR/calls" +case $url in + 'https://api.cloudflare.com/client/v4/zones?name=devantler.tech&per_page=50') cat "$FIXTURE_DIR/zone.json" ;; + 'https://api.cloudflare.com/client/v4/zones/11111111111111111111111111111111/dns_records?per_page=1000&page=1') cat "$FIXTURE_DIR/page1.json" ;; + 'https://api.cloudflare.com/client/v4/zones/11111111111111111111111111111111/dns_records?per_page=1000&page=2') cat "$FIXTURE_DIR/page2.json" ;; + *) exit 89 ;; +esac +[[ $FIXTURE_CASE != http-error ]] +STUB +chmod +x "$scratch/bin/curl" + +run_case() { + local scenario=$1 expected=$2 calls=$3 rc=0 + local ref=refs/heads/main confirmation=EXPORT_ENCRYPTED_DNS event=workflow_dispatch actor=devantler credential=$token + local case_dir="$scratch/$scenario" + mkdir "$case_dir" + cp "$scratch/recipient" "$case_dir/recipient" + : > "$case_dir/calls" + jq -n '{success:true,result:[{id:"11111111111111111111111111111111",name:"devantler.tech",status:"active",account:{id:"22222222222222222222222222222222"}}],result_info:{total_count:1,total_pages:1}}' > "$case_dir/zone.json" + jq -n '{success:true,result:[range(1;1001)|{id:(("00000000000000000000000000000000"+(.|tostring))[-32:]),name:"devantler.tech",type:"TXT",content:("PRIVATE_FIXTURE_"+(.|tostring)),extra:{preserve:true}}],result_info:{page:1,per_page:1000,count:1000,total_count:1001,total_pages:2}}' > "$case_dir/page1.json" + jq -n '{success:true,result:[{id:"ffffffffffffffffffffffffffffffff",name:"mail.devantler.tech",type:"TXT",content:"PRIVATE_FIXTURE_LAST",extra:{preserve:true}}],result_info:{page:2,per_page:1000,count:1,total_count:1001,total_pages:2}}' > "$case_dir/page2.json" + case $scenario in + wrong-ref) ref=refs/heads/unreviewed ;; + wrong-event) event=pull_request ;; + wrong-actor) actor=other-user ;; + disabled) confirmation='' ;; + invalid-confirmation) confirmation='EXPORT_ENCRYPTED_DNS"; exit 0; #' ;; + absent-token) credential='' ;; + invalid-token) credential=$'AAAAAAAAAAAAAAAAAAAA\nheader="injected"' ;; + invalid-recipient) printf '%s\n' age1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq > "$case_dir/recipient" ;; + existing-output) mkdir "$case_dir/out" ;; + invalid-json) printf 'not JSON' > "$case_dir/zone.json" ;; + ambiguous-zone) jq '.result += .result' "$case_dir/zone.json" > "$case_dir/mutant"; mv "$case_dir/mutant" "$case_dir/zone.json" ;; + invalid-zone-id) jq '.result[0].id="../other"' "$case_dir/zone.json" > "$case_dir/mutant"; mv "$case_dir/mutant" "$case_dir/zone.json" ;; + zero-records) jq '.result=[]|.result_info.count=0|.result_info.total_count=0' "$case_dir/page1.json" > "$case_dir/mutant"; mv "$case_dir/mutant" "$case_dir/page1.json" ;; + short-page) jq 'del(.result[0])' "$case_dir/page1.json" > "$case_dir/mutant"; mv "$case_dir/mutant" "$case_dir/page1.json" ;; + wrong-page) jq '.result_info.page=1' "$case_dir/page2.json" > "$case_dir/mutant"; mv "$case_dir/mutant" "$case_dir/page2.json" ;; + drift-total) jq '.result_info.total_count=1002' "$case_dir/page2.json" > "$case_dir/mutant"; mv "$case_dir/mutant" "$case_dir/page2.json" ;; + duplicate) jq '.result[0].id="00000000000000000000000000000001"' "$case_dir/page2.json" > "$case_dir/mutant"; mv "$case_dir/mutant" "$case_dir/page2.json" ;; + foreign-name) jq '.result[0].name="notdevantler.tech"' "$case_dir/page2.json" > "$case_dir/mutant"; mv "$case_dir/mutant" "$case_dir/page2.json" ;; + invalid-record-id) jq '.result[0].id="../other"' "$case_dir/page2.json" > "$case_dir/mutant"; mv "$case_dir/mutant" "$case_dir/page2.json" ;; + provider-error) jq '.success=false' "$case_dir/page2.json" > "$case_dir/mutant"; mv "$case_dir/mutant" "$case_dir/page2.json" ;; + esac + set -- bash "$repo_root/scripts/export-dns-snapshot.sh" + [[ $scenario != inherited-trace ]] || set -- bash -x "$repo_root/scripts/export-dns-snapshot.sh" + env PATH="$scratch/bin:$PATH" FIXTURE_DIR="$case_dir" FIXTURE_CASE="$scenario" \ + GITHUB_EVENT_NAME="$event" GITHUB_REPOSITORY=devantler-tech/cloudflare GITHUB_ACTOR="$actor" GITHUB_REF="$ref" \ + GITHUB_SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa GITHUB_RUN_ATTEMPT=1 \ + CONFIRMATION="$confirmation" CLOUDFLARE_API_TOKEN="$credential" \ + RECIPIENT_FILE="$case_dir/recipient" OUTPUT_DIR="$case_dir/out" "$@" > "$case_dir/log" 2>&1 || rc=$? + [[ $rc == "$expected" ]] || { printf 'FAIL %s: exit %s\n' "$scenario" "$rc"; exit 1; } + [[ $(wc -l < "$case_dir/calls" | tr -d ' ') == "$calls" ]] || { printf 'FAIL %s: request count\n' "$scenario"; exit 1; } + if grep -Eq 'AAAAAAAAAAAA|111111111111|222222222222|PRIVATE_FIXTURE_|Authorization:' "$case_dir/log"; then + printf 'FAIL %s: private log material\n' "$scenario"; exit 1 + fi + if [[ $expected == 0 ]]; then + [[ $(find "$case_dir/out" -type f | wc -l | tr -d ' ') == 1 ]] + if grep -q PRIVATE_FIXTURE_ "$case_dir/out/dns-snapshot.json.age"; then echo 'FAIL: plaintext export'; exit 1; fi + age -d -i "$scratch/key" "$case_dir/out/dns-snapshot.json.age" > "$case_dir/decrypted.json" + jq -e --slurpfile first "$case_dir/page1.json" --slurpfile last "$case_dir/page2.json" ' + .schema == 1 and .source_revision == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + and .records == ($first[0].result+$last[0].result) + ' "$case_dir/decrypted.json" >/dev/null + else + [[ ! -e "$case_dir/out/dns-snapshot.json.age" ]] + fi +} + +run_case success 0 3 +run_case inherited-trace 0 3 +for scenario in wrong-ref wrong-event wrong-actor disabled invalid-confirmation absent-token invalid-token invalid-recipient existing-output; do run_case "$scenario" 2 0; done +for scenario in http-error invalid-json ambiguous-zone invalid-zone-id; do run_case "$scenario" 2 1; done +for scenario in zero-records short-page; do run_case "$scenario" 2 2; done +for scenario in wrong-page drift-total duplicate foreign-name invalid-record-id provider-error; do run_case "$scenario" 2 3; done +printf '%s\n' 'PASS: complete private DNS snapshots and rejected unsafe reads' + +workflow="$repo_root/.github/workflows/dns-snapshot.yaml" +expression=$(jq -nr --arg path "$workflow" '"jsonencode(yamldecode(file(" + ($path|tojson) + ")))"') +printf '%s\n' "$expression" | tofu -chdir="$scratch/parse" console -no-color > "$scratch/workflow-string.json" +jq -r . "$scratch/workflow-string.json" > "$scratch/workflow.json" +check_workflow() { + jq -e ' + .permissions == {contents:"read"} and (.on|keys) == ["workflow_dispatch"] + and (.jobs|keys) == ["snapshot"] + and .concurrency == {group:"encrypted-dns-snapshot", "cancel-in-progress":false} + and .jobs.snapshot["runs-on"] == "ubuntu-latest" and .jobs.snapshot["timeout-minutes"] == 8 + and (.env // {}) == {} and (.jobs.snapshot.env // {}) == {} + and (.jobs.snapshot.permissions // {}) == {} + and (.jobs.snapshot.steps|length) == 5 + and all(.jobs.snapshot.steps[]; (has("if")|not) and (has("continue-on-error")|not)) + and .jobs.snapshot.steps[0].env == {CONFIRMATION:"${{ inputs.confirmation }}"} + and (.jobs.snapshot.steps[0].run|contains("${{")|not) + and .jobs.snapshot.steps[1].uses == "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1" + and .jobs.snapshot.steps[1].with == {ref:"${{ github.sha }}","persist-credentials":false} + and (.jobs.snapshot.steps[1].env // {}) == {} + and (.jobs.snapshot.steps[2].env // {}) == {} + and (.jobs.snapshot.steps[2].run|contains("cbe24006683f8eb669266162894b9a522a1af52f2665fbc63a4bb032ed26ac10")) + and .jobs.snapshot.steps[3].run == "bash scripts/export-dns-snapshot.sh" + and .jobs.snapshot.steps[3].env == {CONFIRMATION:"${{ inputs.confirmation }}",CLOUDFLARE_API_TOKEN:"${{ secrets.CLOUDFLARE_API_TOKEN }}",RECIPIENT_FILE:".github/dns-recovery-recipient.txt",OUTPUT_DIR:"${{ runner.temp }}/cloudflare-export"} + and .jobs.snapshot.steps[4].uses == "actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9" + and .jobs.snapshot.steps[4].with == {name:"encrypted-dns-snapshot",path:"${{ runner.temp }}/cloudflare-export/dns-snapshot.json.age","if-no-files-found":"error","retention-days":1,"compression-level":0,overwrite:false,"include-hidden-files":false} + and (.jobs.snapshot.steps[4].env // {}) == {} + ' "$1" >/dev/null +} +check_workflow "$scratch/workflow.json" +jq -r '.jobs.snapshot.steps[0].run' "$scratch/workflow.json" > "$scratch/guard.sh" +env GITHUB_EVENT_NAME=workflow_dispatch GITHUB_REF=refs/heads/main GITHUB_REPOSITORY=devantler-tech/cloudflare GITHUB_ACTOR=devantler GITHUB_RUN_ATTEMPT=1 CONFIRMATION=EXPORT_ENCRYPTED_DNS bash "$scratch/guard.sh" +for mutation in '.permissions.contents="write"' '.on.push={}' '.jobs.snapshot.steps|=.[1:]' '.jobs.snapshot.steps[0].env.CLOUDFLARE_API_TOKEN="early"' '.jobs.snapshot.steps[4].with.path="/tmp"' '.jobs.snapshot.steps[4].with["retention-days"]=90'; do + jq "$mutation" "$scratch/workflow.json" > "$scratch/mutant.json" + if check_workflow "$scratch/mutant.json"; then echo 'FAIL: unsafe export workflow'; exit 1; fi +done +for scenario in wrong-ref wrong-actor retry wrong-event disabled; do + ref=refs/heads/main actor=devantler attempt=1 event=workflow_dispatch confirmation=EXPORT_ENCRYPTED_DNS + case $scenario in + wrong-ref) ref=refs/heads/unreviewed ;; + wrong-actor) actor=other-user ;; + retry) attempt=2 ;; + wrong-event) event=pull_request ;; + disabled) confirmation='' ;; + esac + if env GITHUB_EVENT_NAME="$event" GITHUB_REF="$ref" GITHUB_REPOSITORY=devantler-tech/cloudflare GITHUB_ACTOR="$actor" GITHUB_RUN_ATTEMPT="$attempt" CONFIRMATION="$confirmation" bash "$scratch/guard.sh" >/dev/null 2>&1; then echo "FAIL: inline guard $scenario"; exit 1; fi +done +printf '%s\n' 'PASS: encrypted export workflow boundaries' From b8a44bebb2e0b9cd2bb7297c7a5fb90c2c57e1d0 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 26 Oct 2026 07:25:26 +0100 Subject: [PATCH 2/2] docs: separate DNS export from migration prerequisites --- docs/runbooks/bootstrap-and-recovery.md | 26 ++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/runbooks/bootstrap-and-recovery.md b/docs/runbooks/bootstrap-and-recovery.md index 33fe63b..3593c71 100644 --- a/docs/runbooks/bootstrap-and-recovery.md +++ b/docs/runbooks/bootstrap-and-recovery.md @@ -15,7 +15,19 @@ 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. -### Encrypted DNS inventory +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, `jq`, and `age` (to decrypt and check the DNS snapshot). +- A short-lived Cloudflare bootstrap token with only the permissions needed to + read and import the inventoried resources. +- Separate R2 S3 credentials scoped only to the state bucket and state object. +- An approved private custody destination reachable during a platform outage. + +Export secrets through the operator's secure session. Do not place values in a +command, `.tfvars`, backend file, CI log, issue, or pull request. + +## Encrypted DNS inventory The **DNS snapshot (encrypted, read-only)** workflow is disabled until a manual dispatch by the maintainer on `main` with `EXPORT_ENCRYPTED_DNS`. Before its @@ -46,18 +58,6 @@ custody before cleanup; do not delete the only recoverable copy. Existing import, state, no-destroy plan and first-apply approval requirements below remain in effect. A completed snapshot does not enable an apply. -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. -- Separate R2 S3 credentials scoped only to the state bucket and state object. -- An approved private custody destination reachable during a platform outage. - -Export secrets through the operator's secure session. Do not place values in a -command, `.tfvars`, backend file, CI log, issue, or pull request. - ## First migration 1. Inventory zones, records, buckets, lifecycle and lock settings, members, and