From 68ffcc787163f754a40abe01f89e29b76c45faa6 Mon Sep 17 00:00:00 2001 From: walter Date: Tue, 6 Oct 2026 16:36:03 +0330 Subject: [PATCH 1/2] fix(ops): remove host node dependency from privacy cleanup --- docs/operations/P7_2_P7_3_RUNBOOK.md | 6 +- docs/privacy/PPR2_DATA_PROTECTION.md | 16 ++- .../wctm-privacy-export-retention.service | 2 +- scripts/ops/archive-erasure-ledger.sh | 7 +- scripts/ops/decrypt-offsite.sh | 11 ++- scripts/ops/offsite-rclone.sh | 12 ++- scripts/ops/offsite-retention.sh | 4 +- scripts/ops/pilot-privacy.sh | 6 +- scripts/ops/privacy-export-artifacts.mjs | 99 ------------------- scripts/ops/privacy-export-artifacts.sh | 69 +++++++++++++ scripts/ops/restore-erasure-ledger.sh | 8 +- scripts/ops/run-ops-node.sh | 36 +++++++ scripts/ops/test-backup-crypto.sh | 10 +- scripts/ops/test-fixtures/docker-node-run.sh | 21 ++++ scripts/ops/test-offsite-retention.sh | 11 ++- scripts/ops/test-pilot-privacy.sh | 13 ++- scripts/ops/test-privacy-export-artifacts.mjs | 42 +++++--- scripts/ops/test-scheduled-backup.sh | 24 ++++- 18 files changed, 244 insertions(+), 153 deletions(-) delete mode 100644 scripts/ops/privacy-export-artifacts.mjs create mode 100755 scripts/ops/privacy-export-artifacts.sh create mode 100755 scripts/ops/run-ops-node.sh create mode 100755 scripts/ops/test-fixtures/docker-node-run.sh diff --git a/docs/operations/P7_2_P7_3_RUNBOOK.md b/docs/operations/P7_2_P7_3_RUNBOOK.md index 01ae291..b8d8830 100644 --- a/docs/operations/P7_2_P7_3_RUNBOOK.md +++ b/docs/operations/P7_2_P7_3_RUNBOOK.md @@ -1,8 +1,10 @@ # P7.2/P7.3 Production Operations Runbook > PPR-2 implementation note: new off-site sets are encrypted before upload and -> require `WCTM_BACKUP_CRYPTO_KEY_FILE` plus a host Node runtime in the scheduled -> service. This change has **not** been production validated. For any restore of +> require `WCTM_BACKUP_CRYPTO_KEY_FILE` plus the locally available, reviewed +> immutable Node base image for a network-disabled encryption container. A +> host Node installation is not required. This change has **not** been +> production validated. For any restore of > an older backup, keep the application stopped and replay the external erasure > ledger before it becomes authoritative. See > `docs/privacy/PPR2_DATA_PROTECTION.md` for the key, legacy plaintext and diff --git a/docs/privacy/PPR2_DATA_PROTECTION.md b/docs/privacy/PPR2_DATA_PROTECTION.md index 88d7d74..46d4eb6 100644 --- a/docs/privacy/PPR2_DATA_PROTECTION.md +++ b/docs/privacy/PPR2_DATA_PROTECTION.md @@ -49,7 +49,7 @@ for that Tenant/Store. After a merchant export has been securely delivered, the operator runs this scoped cleanup to finish that request: ```sh -node scripts/ops/privacy-export-artifacts.mjs purge /var/lib/wctm/privacy TENANT_ID STORE_ID +scripts/ops/privacy-export-artifacts.sh purge /var/lib/wctm/privacy TENANT_ID STORE_ID ``` Delivery @@ -60,7 +60,9 @@ exports. Install and enable the reviewed canonical `/var/lib/wctm/privacy` directory, owned by the `wctm` service account with mode 0700. The timer runs under that existing account and does not require another account to receive Docker or ledger access; its unit hides the -Docker socket and production `.env`. Install the two +Docker socket and production `.env`. Cleanup uses the host's existing Bash, +coreutils and OpenSSL tools; it needs no host Node, Docker access or network. +Install the two unit files from `ops/systemd/`, reload systemd, enable the timer and verify the first one-shot sweep succeeds before using real merchant exports. The hourly timer deletes exact export artifacts at @@ -136,9 +138,13 @@ using a separately stored 32-byte mode-0600 key before rclone upload. OneDrive receives only `.dump.enc`, `.dump.enc.json` and `.dump.enc.sha256` for new sets. AES-256-GCM authenticates ciphertext; local SHA-256 verifies the original dump, and remote content SHA-256 verifies uploaded ciphertext. The key must be owned -by the dedicated backup account outside Git and OneDrive. The scheduled service -requires a host Node runtime and a readable key file; these are new deployment -prerequisites requiring live validation. The decrypted dump is only created in +by the dedicated backup account outside Git and OneDrive. Encryption, metadata +selection and restore use a short-lived, network-disabled container from the +exact reviewed Node 24.20.0 Alpine 3.24 base digest in `backend/Dockerfile`. +The runtime is local-only (`--pull=never`), read-only and given only the +explicit input/key/output mounts. Docker access, that locally built base image +and the readable key file require live validation; no host Node installation is +needed. The decrypted dump is only created in an operator-controlled 0700 directory, then used with the existing isolated restore procedure. Encryption does not replace checksum or restore tests. diff --git a/ops/systemd/wctm-privacy-export-retention.service b/ops/systemd/wctm-privacy-export-retention.service index 6996702..f194302 100644 --- a/ops/systemd/wctm-privacy-export-retention.service +++ b/ops/systemd/wctm-privacy-export-retention.service @@ -6,7 +6,7 @@ Type=oneshot User=wctm Group=wctm WorkingDirectory=/srv/wctm -ExecStart=/usr/bin/env node /srv/wctm/scripts/ops/privacy-export-artifacts.mjs sweep /var/lib/wctm/privacy +ExecStart=/srv/wctm/scripts/ops/privacy-export-artifacts.sh sweep /var/lib/wctm/privacy UMask=0077 NoNewPrivileges=true PrivateTmp=true diff --git a/scripts/ops/archive-erasure-ledger.sh b/scripts/ops/archive-erasure-ledger.sh index eb41560..538ae7b 100755 --- a/scripts/ops/archive-erasure-ledger.sh +++ b/scripts/ops/archive-erasure-ledger.sh @@ -14,10 +14,11 @@ key_file=${WCTM_BACKUP_CRYPTO_KEY_FILE:-} echo >&2 'erasure archive refused: protected ledger, key and rclone configuration are required' exit 66 } -command -v node >/dev/null 2>&1 && command -v rclone >/dev/null 2>&1 || { +command -v docker >/dev/null 2>&1 && command -v rclone >/dev/null 2>&1 || { echo >&2 'erasure archive failed: required local tool is unavailable' exit 69 } +ledger=$(cd "$(dirname "$ledger")" && pwd -P)/$(basename "$ledger") temporary_dir=$(mktemp -d) trap 'rm -rf -- "$temporary_dir"' EXIT HUP INT TERM timestamp=$(date -u '+%Y%m%dT%H%M%SZ') @@ -25,7 +26,9 @@ ledger_sha256=$(sha256sum "$ledger" | awk '{print $1}') name="erasure-ledger-$timestamp-${ledger_sha256:0:12}.jsonl.enc" encrypted="$temporary_dir/$name" crypto_script=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/backup-crypto.mjs -node "$crypto_script" encrypt "$key_file" "$ledger" "$encrypted" || { +scripts/ops/run-ops-node.sh --ro "$crypto_script" --ro "$key_file" \ + --ro "$ledger" --rw "$temporary_dir" -- \ + "$crypto_script" encrypt "$key_file" "$ledger" "$encrypted" || { echo >&2 'erasure archive failed: encryption failed' exit 1 } diff --git a/scripts/ops/decrypt-offsite.sh b/scripts/ops/decrypt-offsite.sh index f88c275..943ddfc 100755 --- a/scripts/ops/decrypt-offsite.sh +++ b/scripts/ops/decrypt-offsite.sh @@ -15,11 +15,12 @@ key_file=${WCTM_BACKUP_CRYPTO_KEY_FILE:-} echo >&2 'off-site decrypt refused: encrypted set, protected key and explicit output directory are required' exit 66 } -command -v node >/dev/null 2>&1 || { - echo >&2 'off-site decrypt failed: local Node runtime is unavailable' +command -v docker >/dev/null 2>&1 || { + echo >&2 'off-site decrypt failed: local Docker runtime is unavailable' exit 69 } encrypted=$(cd "$(dirname "$encrypted")" && pwd -P)/$(basename "$encrypted") +metadata=$(cd "$(dirname "$metadata")" && pwd -P)/$(basename "$metadata") directory=$(cd "$directory" && pwd -P) (cd "$(dirname "$encrypted")" && sha256sum --check --status "$(basename "$encrypted.sha256")") || { echo >&2 'off-site decrypt refused: encrypted content checksum mismatch' @@ -32,7 +33,7 @@ name=$(basename "$encrypted") echo >&2 'off-site decrypt refused: backup set names do not match' exit 65 } -expected=$(node -e ' +expected=$(scripts/ops/run-ops-node.sh --ro "$metadata" -- -e ' const fs = require("node:fs"); const data = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); if (data.format !== "postgresql-custom" || data.offsiteEncryption?.format !== "WCTMBACKUPAESG1" || @@ -50,7 +51,9 @@ dump="$directory/${name%.enc}" exit 73 } crypto_script=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/backup-crypto.mjs -node "$crypto_script" decrypt "$key_file" "$encrypted" "$dump" || { +scripts/ops/run-ops-node.sh --ro "$crypto_script" --ro "$key_file" \ + --ro "$encrypted" --rw "$directory" -- \ + "$crypto_script" decrypt "$key_file" "$encrypted" "$dump" || { echo >&2 'off-site decrypt failed: authentication failed' exit 1 } diff --git a/scripts/ops/offsite-rclone.sh b/scripts/ops/offsite-rclone.sh index c20fabe..929e619 100755 --- a/scripts/ops/offsite-rclone.sh +++ b/scripts/ops/offsite-rclone.sh @@ -22,8 +22,8 @@ command -v rclone >/dev/null 2>&1 || { echo >&2 'off-site copy failed: rclone is unavailable' exit 69 } -command -v node >/dev/null 2>&1 || { - echo >&2 'off-site copy failed: local Node runtime is unavailable for encryption' +command -v docker >/dev/null 2>&1 || { + echo >&2 'off-site copy failed: local Docker runtime is unavailable for encryption' exit 69 } key_file=${WCTM_BACKUP_CRYPTO_KEY_FILE:-} @@ -47,10 +47,14 @@ local_dump_sha256=$(sha256sum "$dump" | awk '{print $1}') } crypto_script=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/backup-crypto.mjs +dump=$(cd "$(dirname "$dump")" && pwd -P)/$(basename "$dump") +metadata=$(cd "$(dirname "$metadata")" && pwd -P)/$(basename "$metadata") temporary_dir=$(mktemp -d "$(dirname "$dump")/.wctm-offsite.XXXXXXXX") trap 'rm -rf -- "$temporary_dir"' EXIT HUP INT TERM encrypted="$temporary_dir/$(basename "$dump").enc" -node "$crypto_script" encrypt "$key_file" "$dump" "$encrypted" || { +scripts/ops/run-ops-node.sh --ro "$crypto_script" --ro "$key_file" \ + --ro "$dump" --rw "$temporary_dir" -- \ + "$crypto_script" encrypt "$key_file" "$dump" "$encrypted" || { echo >&2 'off-site copy failed: local backup encryption failed' exit 1 } @@ -59,7 +63,7 @@ encrypted_sha256=$(sha256sum "$encrypted" | awk '{print $1}') printf '%s %s\n' "$encrypted_sha256" "$(basename "$encrypted")" >"$encrypted_checksum" encrypted_metadata="$encrypted.json" key_id=$(sha256sum "$key_file" | awk '{print substr($1, 1, 16)}') -node -e ' +scripts/ops/run-ops-node.sh --ro "$metadata" --rw "$temporary_dir" -- -e ' const fs = require("node:fs"); const [source, target, keyId, ciphertextSha256] = process.argv.slice(1); const record = JSON.parse(fs.readFileSync(source, "utf8")); diff --git a/scripts/ops/offsite-retention.sh b/scripts/ops/offsite-retention.sh index 672bdc5..80e2952 100755 --- a/scripts/ops/offsite-retention.sh +++ b/scripts/ops/offsite-retention.sh @@ -11,7 +11,7 @@ days=$2 echo >&2 'off-site retention refused: max age must be positive whole days' exit 64 } -command -v rclone >/dev/null 2>&1 && command -v node >/dev/null 2>&1 || { +command -v rclone >/dev/null 2>&1 && command -v docker >/dev/null 2>&1 || { echo >&2 'off-site retention failed: required local tool is unavailable' exit 69 } @@ -20,7 +20,7 @@ listing=$(rclone lsf --files-only "$destination") || { echo >&2 'off-site retention failed: remote listing unavailable' exit 1 } -selected=$(printf '%s\n' "$listing" | node "$selector" "$days") || { +selected=$(printf '%s\n' "$listing" | scripts/ops/run-ops-node.sh --ro "$selector" -- "$selector" "$days") || { echo >&2 'off-site retention failed: selection unavailable' exit 1 } diff --git a/scripts/ops/pilot-privacy.sh b/scripts/ops/pilot-privacy.sh index 126477c..bebb991 100755 --- a/scripts/ops/pilot-privacy.sh +++ b/scripts/ops/pilot-privacy.sh @@ -57,7 +57,7 @@ case "$mode" in esac # Expired exports are swept on every operator entry as well as by the hourly timer. -node scripts/ops/privacy-export-artifacts.mjs sweep "$privacy_directory" +scripts/ops/privacy-export-artifacts.sh sweep "$privacy_directory" target_ids() { tenant_id='' @@ -82,7 +82,7 @@ if [[ "$mode" = export ]]; then exit 64 } target_ids "$@" || { echo >&2 'privacy export refused: exact target IDs are required'; exit 64; } - output=$(node scripts/ops/privacy-export-artifacts.mjs new-path "$privacy_directory" "$tenant_id" "$store_id") + output=$(scripts/ops/privacy-export-artifacts.sh new-path "$privacy_directory" "$tenant_id" "$store_id") set -- "$@" --output "$output" fi @@ -125,7 +125,7 @@ if [[ "$mode" = erase && " $* " = *' --execute '* ]]; then scripts/ops/archive-erasure-ledger.sh "$ledger" "${WCTM_OFFSITE_DESTINATION%/}/privacy-erasure-ledger" run_privacy erase "$@" target_ids "$@" - node scripts/ops/privacy-export-artifacts.mjs purge "$privacy_directory" "$tenant_id" "$store_id" + scripts/ops/privacy-export-artifacts.sh purge "$privacy_directory" "$tenant_id" "$store_id" else run_privacy "$mode" "$@" if [[ "$mode" = export ]]; then diff --git a/scripts/ops/privacy-export-artifacts.mjs b/scripts/ops/privacy-export-artifacts.mjs deleted file mode 100644 index b6278d2..0000000 --- a/scripts/ops/privacy-export-artifacts.mjs +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env node -import { createHash, randomBytes } from 'node:crypto'; -import { lstat, readdir, realpath, unlink } from 'node:fs/promises'; -import { join, resolve } from 'node:path'; - -const namePattern = - /^wctm-privacy-export-([0-9a-f]{64})-([0-9]{13})-([0-9a-f]{16})\.json$/; -// An hourly timer removes files at 23 hours, leaving room before the 24-hour limit. -const sweepAgeMs = 23 * 60 * 60 * 1000; - -function scopeHash(tenantId, storeId) { - if ( - !/^ten_[A-Za-z0-9_-]+$/.test(tenantId) || - !/^sto_[A-Za-z0-9_-]+$/.test(storeId) - ) { - throw new Error('privacy export: exact Tenant and Store IDs are required'); - } - return createHash('sha256').update(`${tenantId}\0${storeId}`).digest('hex'); -} - -async function protectedDirectory(path) { - if ( - !path?.startsWith('/') || - resolve(path) === '/' || - path.split('/').includes('..') - ) { - throw new Error( - 'privacy export: an absolute protected directory is required' - ); - } - const details = await lstat(path); - if (!details.isDirectory() || (details.mode & 0o777) !== 0o700) { - throw new Error('privacy export: directory must be a 0700 real directory'); - } - const directory = await realpath(path); - const ledger = await lstat(join(directory, 'erasure-ledger.jsonl')); - if (!ledger.isFile() || (ledger.mode & 0o777) !== 0o600) { - throw new Error('privacy export: protected erasure ledger is required'); - } - return directory; -} - -async function removeEligible(directory, selectedScope) { - let removed = 0; - const now = Date.now(); - for (const name of await readdir(directory)) { - const match = namePattern.exec(name); - if (!match || (selectedScope && match[1] !== selectedScope)) continue; - const createdAt = Number(match[2]); - if (!Number.isSafeInteger(createdAt)) continue; - const path = join(directory, name); - const details = await lstat(path); - if (!details.isFile() || (details.mode & 0o777) !== 0o600) { - throw new Error( - 'privacy export: matching artifact is not a 0600 regular file' - ); - } - const earliestAgeEvidence = Math.min( - createdAt, - details.mtimeMs, - details.birthtimeMs > 0 ? details.birthtimeMs : Infinity - ); - if (!selectedScope && now - earliestAgeEvidence < sweepAgeMs) continue; - await unlink(path); - removed++; - } - return removed; -} - -async function main() { - const [mode, directoryArg, tenantId, storeId] = process.argv.slice(2); - if ( - !['new-path', 'sweep', 'purge'].includes(mode) || - (mode === 'sweep' && process.argv.length !== 4) || - (mode !== 'sweep' && process.argv.length !== 6) - ) { - throw new Error( - 'usage: privacy-export-artifacts.mjs new-path|purge DIRECTORY TENANT_ID STORE_ID | sweep DIRECTORY' - ); - } - const directory = await protectedDirectory(directoryArg); - if (mode === 'new-path') { - const name = `wctm-privacy-export-${scopeHash(tenantId, storeId)}-${Date.now()}-${randomBytes(8).toString('hex')}.json`; - process.stdout.write(`${join(directory, name)}\n`); - return; - } - const removed = await removeEligible( - directory, - mode === 'purge' ? scopeHash(tenantId, storeId) : undefined - ); - process.stdout.write(`privacy export cleanup: PASS removed=${removed}\n`); -} - -main().catch(() => { - process.stderr.write( - 'privacy export cleanup failed; inspect protected directory and operator configuration\n' - ); - process.exitCode = 1; -}); diff --git a/scripts/ops/privacy-export-artifacts.sh b/scripts/ops/privacy-export-artifacts.sh new file mode 100755 index 0000000..9256690 --- /dev/null +++ b/scripts/ops/privacy-export-artifacts.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +fail() { + echo >&2 'privacy export cleanup failed; inspect protected directory and operator configuration' + exit 1 +} + +if [[ $# != 2 && $# != 4 ]] || + { [[ $1 = sweep && $# != 2 ]] || [[ $1 != sweep && $# != 4 ]]; }; then + echo >&2 'usage: privacy-export-artifacts.sh new-path|purge DIRECTORY TENANT_ID STORE_ID | sweep DIRECTORY' + exit 64 +fi +mode=$1 +directory=$2 +[[ $mode = sweep || $mode = purge || $mode = new-path ]] || fail +[[ $directory = /* && $directory != / && /$directory/ != */../* && + -d $directory && ! -L $directory ]] || fail +directory_mode=$(stat -c '%a' "$directory" 2>/dev/null || stat -f '%Lp' "$directory") || fail +[[ $directory_mode = 700 ]] || fail +directory=$(cd "$directory" && pwd -P) || fail +ledger=$directory/erasure-ledger.jsonl +[[ -f $ledger && ! -L $ledger ]] || fail +ledger_mode=$(stat -c '%a' "$ledger" 2>/dev/null || stat -f '%Lp' "$ledger") || fail +[[ $ledger_mode = 600 ]] || fail + +scope_hash() { + [[ $1 =~ ^ten_[A-Za-z0-9_-]+$ && $2 =~ ^sto_[A-Za-z0-9_-]+$ ]] || fail + if command -v sha256sum >/dev/null 2>&1; then + printf '%s\0%s' "$1" "$2" | sha256sum | awk '{print $1}' + else + printf '%s\0%s' "$1" "$2" | shasum -a 256 | awk '{print $1}' + fi +} + +if [[ $mode = new-path ]]; then + scope=$(scope_hash "$3" "$4") || fail + timestamp=$(date -u '+%s%3N' 2>/dev/null) || timestamp='' + [[ $timestamp =~ ^[0-9]{13}$ ]] || timestamp="$(date -u '+%s')000" + nonce=$(openssl rand -hex 8) || fail + printf '%s/wctm-privacy-export-%s-%s-%s.json\n' "$directory" "$scope" "$timestamp" "$nonce" + exit 0 +fi + +selected_scope='' +[[ $mode != purge ]] || selected_scope=$(scope_hash "$3" "$4") || fail +now=$(date -u '+%s') || fail +removed=0 +shopt -s nullglob +for path in "$directory"/wctm-privacy-export-*; do + name=${path##*/} + [[ $name =~ ^wctm-privacy-export-([0-9a-f]{64})-([0-9]{13})-([0-9a-f]{16})\.json$ ]] || continue + [[ -z $selected_scope || ${BASH_REMATCH[1]} = "$selected_scope" ]] || continue + [[ -f $path && ! -L $path ]] || fail + file_mode=$(stat -c '%a' "$path" 2>/dev/null || stat -f '%Lp' "$path") || fail + [[ $file_mode = 600 ]] || fail + if [[ $mode = sweep ]]; then + oldest=$((${BASH_REMATCH[2]:0:10})) + modified=$(stat -c '%Y' "$path" 2>/dev/null || stat -f '%m' "$path") || fail + (( modified < oldest )) && oldest=$modified + born=$(stat -c '%W' "$path" 2>/dev/null || stat -f '%B' "$path") || fail + (( born > 0 && born < oldest )) && oldest=$born + (( now - oldest >= 23 * 3600 )) || continue + fi + rm -f -- "$path" || fail + removed=$((removed + 1)) +done +printf 'privacy export cleanup: PASS removed=%s\n' "$removed" diff --git a/scripts/ops/restore-erasure-ledger.sh b/scripts/ops/restore-erasure-ledger.sh index 00a7aaf..18f16da 100755 --- a/scripts/ops/restore-erasure-ledger.sh +++ b/scripts/ops/restore-erasure-ledger.sh @@ -20,13 +20,15 @@ name=$(basename "$encrypted") echo >&2 'erasure ledger restore refused: encrypted set name is invalid' exit 65 } +encrypted=$(cd "$(dirname "$encrypted")" && pwd -P)/$name +directory=$(cd "$directory" && pwd -P) (cd "$(dirname "$encrypted")" && sha256sum --check --status "$name.sha256") || { echo >&2 'erasure ledger restore refused: encrypted checksum mismatch' exit 1 } key_id=$(sha256sum "$key_file" | awk '{print substr($1, 1, 16)}') cipher_sha256=$(sha256sum "$encrypted" | awk '{print $1}') -expected_plaintext=$(node -e ' +expected_plaintext=$(scripts/ops/run-ops-node.sh --ro "$encrypted.json" -- -e ' const fs = require("node:fs"); const value = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); if (value.format !== "WCTMBACKUPAESG1" || value.keyId !== process.argv[2] || @@ -43,7 +45,9 @@ output="$directory/erasure-ledger.jsonl" exit 73 } crypto_script=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/backup-crypto.mjs -node "$crypto_script" decrypt "$key_file" "$encrypted" "$output" || { +scripts/ops/run-ops-node.sh --ro "$crypto_script" --ro "$key_file" \ + --ro "$encrypted" --rw "$directory" -- \ + "$crypto_script" decrypt "$key_file" "$encrypted" "$output" || { echo >&2 'erasure ledger restore failed: authentication failed' exit 1 } diff --git a/scripts/ops/run-ops-node.sh b/scripts/ops/run-ops-node.sh new file mode 100755 index 0000000..abb33cf --- /dev/null +++ b/scripts/ops/run-ops-node.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +fail() { + echo >&2 'containerized operations runtime failed; check the reviewed local image and protected mounts' + exit 1 +} + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P) +image=$(awk 'NR == 1 {print $2}' "$repo_root/backend/Dockerfile") +[[ $image =~ ^node:24\.20\.0-alpine3\.24@sha256:[0-9a-f]{64}$ ]] || fail +mounts=() +while (($#)); do + case "$1" in + --ro|--rw) + [[ $# -ge 2 && $2 = /* && $2 != *:* && $2 != *,* && $2 != *$'\n'* && $2 != / ]] || fail + path=$2 + [[ -f $path || -d $path ]] && [[ ! -L $path ]] || fail + if [[ $1 = --ro ]]; then + mounts+=(--mount "type=bind,source=$path,target=$path,readonly") + else + [[ -d $path ]] || fail + mounts+=(--mount "type=bind,source=$path,target=$path") + fi + shift 2 ;; + --) + shift + break ;; + *) fail ;; + esac +done +[[ $# -ge 1 ]] || fail +docker run --rm --interactive --pull=never --network=none --read-only \ + --user "$(id -u):$(id -g)" --cap-drop=ALL \ + --security-opt=no-new-privileges --pids-limit=64 \ + "${mounts[@]}" --entrypoint node "$image" "$@" || fail diff --git a/scripts/ops/test-backup-crypto.sh b/scripts/ops/test-backup-crypto.sh index 28d9fe9..f8327c3 100755 --- a/scripts/ops/test-backup-crypto.sh +++ b/scripts/ops/test-backup-crypto.sh @@ -33,9 +33,13 @@ fi mkdir "$workspace/bin" "$workspace/remote" cp scripts/ops/test-fixtures/rclone "$workspace/bin/rclone" -chmod 0700 "$workspace/bin/rclone" +cp scripts/ops/test-fixtures/docker-node-run.sh "$workspace/bin/docker" +chmod 0700 "$workspace/bin/rclone" "$workspace/bin/docker" +ln -s "$(command -v sha256sum)" "$workspace/bin/sha256sum" +export TEST_NODE=$(command -v node) printf '[testremote]\ntype = local\n' >"$workspace/rclone.conf" -export PATH="$workspace/bin:$PATH" +export PATH="$workspace/bin:/usr/bin:/bin" +! command -v node >/dev/null 2>&1 export RCLONE_CONFIG="$workspace/rclone.conf" export WCTM_BACKUP_CRYPTO_KEY_FILE="$workspace/key" printf '{"tenantId":"ten_fixture","storeId":"sto_fixture","baseUrlSha256":"%064d"}\n' 0 >"$workspace/ledger.jsonl" @@ -51,4 +55,4 @@ scripts/ops/restore-erasure-ledger.sh "$encrypted_ledger" "$workspace/ledger-res cmp "$workspace/ledger.jsonl" "$workspace/ledger-restored/erasure-ledger.jsonl" grep -q 'erasure ledger restore: PASS' "$workspace/ledger-restore.log" ! grep -q 'synthetic customer marker' "$workspace"/*.log -echo 'backup encryption: PASS roundtrip wrong-key tamper encrypted-ledger-archive no-plaintext-output secret-safe-errors' +echo 'backup encryption: PASS roundtrip wrong-key tamper encrypted-ledger-archive no-host-node no-plaintext-output secret-safe-errors' diff --git a/scripts/ops/test-fixtures/docker-node-run.sh b/scripts/ops/test-fixtures/docker-node-run.sh new file mode 100755 index 0000000..b65b34e --- /dev/null +++ b/scripts/ops/test-fixtures/docker-node-run.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +[[ ${1:-} = run && " $* " = *' --pull=never '* && + " $* " = *' --network=none '* && " $* " = *' --read-only '* ]] || exit 99 +while (($#)); do + case "$1" in + run|--rm|--interactive|--pull=never|--network=none|--read-only|--cap-drop=ALL|--security-opt=no-new-privileges|--pids-limit=64) + shift ;; + --user|--mount) + shift 2 ;; + --entrypoint) + [[ ${2:-} = node ]] || exit 99 + shift 2 + [[ ${1:-} =~ ^node:24\.20\.0-alpine3\.24@sha256:[0-9a-f]{64}$ ]] || exit 99 + shift + exec "$TEST_NODE" "$@" ;; + *) exit 99 ;; + esac +done +exit 99 diff --git a/scripts/ops/test-offsite-retention.sh b/scripts/ops/test-offsite-retention.sh index 899b3c3..60f4510 100755 --- a/scripts/ops/test-offsite-retention.sh +++ b/scripts/ops/test-offsite-retention.sh @@ -3,13 +3,16 @@ set -euo pipefail workspace=$(mktemp -d) trap 'rm -rf -- "$workspace"' EXIT HUP INT TERM +export TEST_NODE=$(command -v node) mkdir "$workspace/bin" "$workspace/remote" cp scripts/ops/test-fixtures/rclone "$workspace/bin/rclone" -chmod 0700 "$workspace/bin/rclone" -export PATH="$workspace/bin:$PATH" +cp scripts/ops/test-fixtures/docker-node-run.sh "$workspace/bin/docker" +chmod 0700 "$workspace/bin/rclone" "$workspace/bin/docker" +export PATH="$workspace/bin:/usr/bin:/bin" +! command -v node >/dev/null 2>&1 stamp() { - node -e 'process.stdout.write(new Date(Date.now() - Number(process.argv[1]) * 86400000).toISOString().replace(/[-:]/g, "").slice(0, 15) + "Z")' "$1" + "$TEST_NODE" -e 'process.stdout.write(new Date(Date.now() - Number(process.argv[1]) * 86400000).toISOString().replace(/[-:]/g, "").slice(0, 15) + "Z")' "$1" } create_set() { base="wctm-postgres-$(stamp "$1")-deadbeef0000" @@ -31,4 +34,4 @@ scripts/ops/offsite-retention.sh "testremote:$workspace/remote" 30 >"$workspace/ [[ -f "$workspace/remote/$legacy" && -f "$workspace/remote/$incomplete" ]] [[ $(find "$workspace/remote" -maxdepth 1 -name '*.dump.enc.sha256' | wc -l | tr -d ' ') = 2 ]] grep -q 'removed_artifacts=6 legacy_plaintext_untouched=true' "$workspace/result.log" -echo 'off-site retention: PASS 30-day policy newest-two protected incomplete/legacy preserved' +echo 'off-site retention: PASS no-host-node 30-day policy newest-two protected incomplete/legacy preserved' diff --git a/scripts/ops/test-pilot-privacy.sh b/scripts/ops/test-pilot-privacy.sh index c00253f..5efee2d 100755 --- a/scripts/ops/test-pilot-privacy.sh +++ b/scripts/ops/test-pilot-privacy.sh @@ -4,6 +4,8 @@ umask 077 workspace=$(mktemp -d) trap 'rm -rf -- "$workspace"' EXIT HUP INT TERM +export TEST_NODE=$(command -v node) +export TEST_DOCKER_NODE_FIXTURE="$(pwd -P)/scripts/ops/test-fixtures/docker-node-run.sh" mkdir -m 0700 "$workspace/privacy" "$workspace/bin" "$workspace/remote" printf 'DATABASE_URL=postgresql://owner:fixture-only@postgres/wctm\n' >"$workspace/database.conf" printf '{"tenantId":"ten_fixture","storeId":"sto_fixture","baseUrlSha256":"%064d"}\n' 0 \ @@ -14,6 +16,9 @@ cp scripts/ops/test-fixtures/rclone "$workspace/bin/rclone" cat >"$workspace/bin/docker" <<'SH' #!/usr/bin/env bash printf '%s\n' "$*" >>"$TEST_DOCKER_LOG" +if [[ ${1:-} = run ]]; then + exec "$TEST_DOCKER_NODE_FIXTURE" "$@" +fi previous='' for argument in "$@"; do if [[ "$previous" = --output ]]; then @@ -28,7 +33,9 @@ if [[ "$*" = 'compose ps --status running -q backend telegram-bot' && fi SH chmod 0700 "$workspace/bin/docker" "$workspace/bin/rclone" -export PATH="$workspace/bin:$PATH" +ln -s "$(command -v sha256sum)" "$workspace/bin/sha256sum" +export PATH="$workspace/bin:/usr/bin:/bin" +! command -v node >/dev/null 2>&1 export TEST_DOCKER_LOG="$workspace/docker.log" export TEST_PRIVACY_DIRECTORY="$workspace/privacy" export WCTM_BACKUP_CRYPTO_KEY_FILE="$workspace/key" @@ -74,7 +81,7 @@ if scripts/ops/pilot-privacy.sh --credential-file "$workspace/database.conf" \ echo >&2 'test failed: erasure proceeded without off-site ledger destination' exit 1 fi -[[ $(wc -l <"$workspace/docker.log" | tr -d ' ') = 6 ]] +[[ $(wc -l <"$workspace/docker.log" | tr -d ' ') = 7 ]] export WCTM_OFFSITE_DESTINATION="testremote:$workspace/remote" if TEST_APP_RUNNING=true scripts/ops/pilot-privacy.sh \ @@ -86,4 +93,4 @@ if TEST_APP_RUNNING=true scripts/ops/pilot-privacy.sh \ exit 1 fi grep -q 'stop backend and bot' "$workspace/running.log" -echo 'pilot privacy wrapper: PASS protected-config explicit export retention erase cleanup archive-before-erasure fail-closed' +echo 'pilot privacy wrapper: PASS no-host-node protected-config explicit export retention erase cleanup archive-before-erasure fail-closed' diff --git a/scripts/ops/test-privacy-export-artifacts.mjs b/scripts/ops/test-privacy-export-artifacts.mjs index a236e3e..0fb2f5f 100644 --- a/scripts/ops/test-privacy-export-artifacts.mjs +++ b/scripts/ops/test-privacy-export-artifacts.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { mkdtemp, mkdir, @@ -16,10 +17,18 @@ import { join } from 'node:path'; const root = await mkdtemp(join(tmpdir(), 'wctm-privacy-export-test-')); const directory = join(root, 'privacy'); const outside = join(root, 'outside'); -const helper = 'scripts/ops/privacy-export-artifacts.mjs'; +const helper = 'scripts/ops/privacy-export-artifacts.sh'; +const noNodeEnvironment = { ...process.env, PATH: '/usr/bin:/bin' }; +assert.notEqual( + spawnSync('/bin/bash', ['-c', 'command -v node'], { + env: noNodeEnvironment, + }).status, + 0 +); const run = (...args) => - execFileSync(process.execPath, [helper, ...args], { + execFileSync('/bin/bash', [helper, ...args], { encoding: 'utf8', + env: noNodeEnvironment, }).trim(); const tenantA = 'ten_fixture_a'; const storeA = 'sto_fixture_a'; @@ -59,6 +68,10 @@ try { } const stale = await create(tenantA, storeA, 25); const fresh = await create(tenantA, storeA); + const expectedScope = createHash('sha256') + .update(`${tenantA}\0${storeA}`) + .digest('hex'); + assert.ok(fresh.includes(`wctm-privacy-export-${expectedScope}-`)); const otherScope = await create(tenantB, storeB, 25); const oldMtime = await create(tenantA, storeB); const yesterday = new Date(Date.now() - 25 * 3600000); @@ -88,30 +101,29 @@ try { `-${Date.now() - 25 * 3600000}-$1` ); await symlink(outsideFile, symlinkName); - const refused = spawnSync(process.execPath, [helper, 'sweep', directory], { + const refused = spawnSync('/bin/bash', [helper, 'sweep', directory], { encoding: 'utf8', + env: noNodeEnvironment, }); assert.notEqual(refused.status, 0); assert.equal(await exists(outsideFile), true); const escape = spawnSync( - process.execPath, + '/bin/bash', [helper, 'sweep', `${directory}/../outside`], - { encoding: 'utf8' } + { encoding: 'utf8', env: noNodeEnvironment } ); assert.notEqual(escape.status, 0); - const wrongDirectory = spawnSync( - process.execPath, - [helper, 'sweep', outside], - { encoding: 'utf8' } - ); + const wrongDirectory = spawnSync('/bin/bash', [helper, 'sweep', outside], { + encoding: 'utf8', + env: noNodeEnvironment, + }); assert.notEqual(wrongDirectory.status, 0); const linkedDirectory = join(root, 'linked-privacy'); await symlink(directory, linkedDirectory); - const linked = spawnSync( - process.execPath, - [helper, 'sweep', linkedDirectory], - { encoding: 'utf8' } - ); + const linked = spawnSync('/bin/bash', [helper, 'sweep', linkedDirectory], { + encoding: 'utf8', + env: noNodeEnvironment, + }); assert.notEqual(linked.status, 0); assert.equal(await exists(outsideFile), true); assert.doesNotMatch( diff --git a/scripts/ops/test-scheduled-backup.sh b/scripts/ops/test-scheduled-backup.sh index b88b9e1..1d06631 100755 --- a/scripts/ops/test-scheduled-backup.sh +++ b/scripts/ops/test-scheduled-backup.sh @@ -2,12 +2,17 @@ set -euo pipefail fixture=$(realpath scripts/ops/test-fixtures/rclone) +export TEST_DOCKER_NODE_FIXTURE="$(pwd -P)/scripts/ops/test-fixtures/docker-node-run.sh" +export TEST_NODE=$(command -v node) workspace=$(mktemp -d) trap 'chmod 0600 "$workspace/rclone.conf" "$workspace/.env" 2>/dev/null || true; rm -rf -- "$workspace"' EXIT -mkdir -p "$workspace/scripts/ops" "$workspace/bin" "$workspace/backups" +mkdir -p "$workspace/scripts/ops" "$workspace/bin" "$workspace/backups" "$workspace/backend" +cp backend/Dockerfile "$workspace/backend/Dockerfile" cp scripts/ops/scheduled-backup.sh scripts/ops/backup-postgres.sh \ scripts/ops/offsite-rclone.sh scripts/ops/offsite-retention.sh \ - scripts/ops/backup-crypto.mjs scripts/ops/select-offsite-expired.mjs "$workspace/scripts/ops/" + scripts/ops/decrypt-offsite.sh \ + scripts/ops/run-ops-node.sh scripts/ops/backup-crypto.mjs \ + scripts/ops/select-offsite-expired.mjs "$workspace/scripts/ops/" cat >"$workspace/scripts/ops/backup-retention.sh" <<'SH' #!/usr/bin/env bash @@ -54,6 +59,9 @@ case "${1:-}" in echo >&2 'test failed: scheduled backup invoked Docker Compose' exit 99 ;; + run) + exec "$TEST_DOCKER_NODE_FIXTURE" "$@" + ;; *) exit 99 ;; esac SH @@ -72,6 +80,7 @@ set -euo pipefail exec "$TEST_RCLONE_FIXTURE" "$@" SH chmod +x "$workspace"/scripts/ops/*.sh "$workspace"/bin/* +ln -s "$(command -v sha256sum)" "$workspace/bin/sha256sum" export TEST_WORKSPACE=$workspace TEST_RCLONE_FIXTURE=$fixture export TEST_EXPECTED_CONFIG="$workspace/rclone.conf" @@ -79,7 +88,8 @@ export TEST_EXPECTED_CONTAINER=$(printf 'a%.0s' {1..64}) export TEST_SECOND_CONTAINER=$(printf 'b%.0s' {1..64}) export WCTM_BACKUP_DIRECTORY="$workspace/backups" export WCTM_COMPOSE_PROJECT=wctm -export PATH="$workspace/bin:$PATH" +export PATH="$workspace/bin:/usr/bin:/bin" +! command -v node >/dev/null 2>&1 printf '[testremote]\ntype = local\n' >"$TEST_EXPECTED_CONFIG" chmod 0600 "$TEST_EXPECTED_CONFIG" export RCLONE_CONFIG=$TEST_EXPECTED_CONFIG @@ -107,6 +117,12 @@ grep -q '"format": "postgresql-custom"' "$dump.json" grep -q '"completedMigrationCount": 16' "$dump.json" [[ -f "$workspace/native/$(basename "$dump").enc" && -f "$workspace/native/$(basename "$dump").enc.json" && -f "$workspace/native/$(basename "$dump").enc.sha256" ]] [[ ! -e "$workspace/native/$(basename "$dump")" ]] +mkdir "$workspace/decrypted" +(cd "$workspace" && scripts/ops/decrypt-offsite.sh \ + "$workspace/native/$(basename "$dump").enc" \ + "$workspace/native/$(basename "$dump").enc.json" \ + "$workspace/decrypted") >"$workspace/decrypt.log" 2>&1 +cmp "$dump" "$workspace/decrypted/$(basename "$dump")" if grep -q '^compose ' "$workspace/docker.log"; then echo >&2 'test failed: backup required Docker Compose and its .env' exit 1 @@ -176,4 +192,4 @@ if grep -q 'scheduled backup: PASS' "$workspace/corrupt.log" "$workspace/missing echo >&2 'test failed: unsuccessful backup reported PASS' exit 1 fi -echo 'scheduled backup: PASS no-compose-env exact-container missing-config rclone-path custom-format SHA-256 offsite-integrity secret-safe-logs' +echo 'scheduled backup: PASS no-host-node no-compose-env exact-container missing-config rclone-path custom-format SHA-256 offsite-integrity secret-safe-logs' From 832d2e888332ba130fe24dfcff662f93a6067518 Mon Sep 17 00:00:00 2001 From: walter Date: Tue, 6 Oct 2026 17:05:50 +0330 Subject: [PATCH 2/2] fix(security): harden privacy operations runtime --- backend/Dockerfile | 15 +++++++++++++++ docs/privacy/PPR2_DATA_PROTECTION.md | 12 ++++++++---- scripts/ops/run-ops-node.sh | 9 +++++---- scripts/ops/test-backup-crypto.sh | 10 +++++++++- scripts/ops/test-fixtures/docker-node-run.sh | 8 +++++++- scripts/ops/test-pilot-privacy.sh | 2 +- scripts/ops/test-scheduled-backup.sh | 6 ++++-- 7 files changed, 49 insertions(+), 13 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index c82d6ed..bbc5542 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -4,6 +4,21 @@ COPY scripts/ops/install-pinned-openssl.sh /tmp/install-pinned-openssl.sh RUN sh /tmp/install-pinned-openssl.sh && rm /tmp/install-pinned-openssl.sh +FROM openssl-pinned AS privacy-ops + +ENV NODE_ENV=production +LABEL org.wctm.runtime="privacy-ops" + +RUN apk add --repositories-file /dev/null --no-network ca-certificates-bundle \ + && apk del --repositories-file /dev/null --no-network apk-tools \ + && rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \ + /usr/local/lib/node_modules/corepack /usr/local/bin/corepack \ + /usr/local/bin/yarn /usr/local/bin/yarnpkg /opt/yarn-v1.22.22 + +USER node + +ENTRYPOINT ["node"] + FROM openssl-pinned AS dependencies WORKDIR /app diff --git a/docs/privacy/PPR2_DATA_PROTECTION.md b/docs/privacy/PPR2_DATA_PROTECTION.md index 46d4eb6..fb755b2 100644 --- a/docs/privacy/PPR2_DATA_PROTECTION.md +++ b/docs/privacy/PPR2_DATA_PROTECTION.md @@ -139,10 +139,14 @@ receives only `.dump.enc`, `.dump.enc.json` and `.dump.enc.sha256` for new sets. AES-256-GCM authenticates ciphertext; local SHA-256 verifies the original dump, and remote content SHA-256 verifies uploaded ciphertext. The key must be owned by the dedicated backup account outside Git and OneDrive. Encryption, metadata -selection and restore use a short-lived, network-disabled container from the -exact reviewed Node 24.20.0 Alpine 3.24 base digest in `backend/Dockerfile`. -The runtime is local-only (`--pull=never`), read-only and given only the -explicit input/key/output mounts. Docker access, that locally built base image +selection and restore use the dedicated `privacy-ops` target in +`backend/Dockerfile`. Build it locally before backup or privacy operations: +`docker build --pull=false --target privacy-ops -t wctm-privacy-ops:node24.20.0-alpine3.24-openssl3.5.9-r0 -f backend/Dockerfile .`. +It inherits the immutable reviewed Node 24.20.0 / Alpine 3.24 base and the +SHA-256-verified OpenSSL 3.5.9-r0 package installation, then removes global npm, +Corepack, Yarn and APK tooling. The runner resolves the reviewed local tag to an immutable +image ID and uses it with `--pull=never`, no network, a read-only root and only +explicit input/key/output mounts. Docker access, that locally built ops image and the readable key file require live validation; no host Node installation is needed. The decrypted dump is only created in an operator-controlled 0700 directory, then used with the existing isolated diff --git a/scripts/ops/run-ops-node.sh b/scripts/ops/run-ops-node.sh index abb33cf..5160b41 100755 --- a/scripts/ops/run-ops-node.sh +++ b/scripts/ops/run-ops-node.sh @@ -6,9 +6,10 @@ fail() { exit 1 } -repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P) -image=$(awk 'NR == 1 {print $2}' "$repo_root/backend/Dockerfile") -[[ $image =~ ^node:24\.20\.0-alpine3\.24@sha256:[0-9a-f]{64}$ ]] || fail +image_tag=wctm-privacy-ops:node24.20.0-alpine3.24-openssl3.5.9-r0 +image_details=$(docker image inspect --format '{{.Id}} {{index .Config.Labels "org.wctm.runtime"}} {{.Config.User}}' "$image_tag" 2>/dev/null) || fail +read -r image runtime_label image_user <<<"$image_details" +[[ $image =~ ^sha256:[0-9a-f]{64}$ && $runtime_label = privacy-ops && $image_user = node ]] || fail mounts=() while (($#)); do case "$1" in @@ -33,4 +34,4 @@ done docker run --rm --interactive --pull=never --network=none --read-only \ --user "$(id -u):$(id -g)" --cap-drop=ALL \ --security-opt=no-new-privileges --pids-limit=64 \ - "${mounts[@]}" --entrypoint node "$image" "$@" || fail + ${mounts[@]+"${mounts[@]}"} --entrypoint node "$image" "$@" || fail diff --git a/scripts/ops/test-backup-crypto.sh b/scripts/ops/test-backup-crypto.sh index f8327c3..8f0a1c8 100755 --- a/scripts/ops/test-backup-crypto.sh +++ b/scripts/ops/test-backup-crypto.sh @@ -40,6 +40,14 @@ export TEST_NODE=$(command -v node) printf '[testremote]\ntype = local\n' >"$workspace/rclone.conf" export PATH="$workspace/bin:/usr/bin:/bin" ! command -v node >/dev/null 2>&1 +for invalid_image in "$(printf 'sha256:%064d raw-base node' 0)" "$(printf 'sha256:%064d privacy-ops root' 0)"; do + if TEST_DOCKER_IMAGE_DETAILS="$invalid_image" scripts/ops/run-ops-node.sh -- -e 'process.exit(0)' \ + >"$workspace/invalid-image.log" 2>&1; then + echo >&2 'test failed: unreviewed or root ops image was accepted' + exit 1 + fi + grep -q 'containerized operations runtime failed' "$workspace/invalid-image.log" +done export RCLONE_CONFIG="$workspace/rclone.conf" export WCTM_BACKUP_CRYPTO_KEY_FILE="$workspace/key" printf '{"tenantId":"ten_fixture","storeId":"sto_fixture","baseUrlSha256":"%064d"}\n' 0 >"$workspace/ledger.jsonl" @@ -55,4 +63,4 @@ scripts/ops/restore-erasure-ledger.sh "$encrypted_ledger" "$workspace/ledger-res cmp "$workspace/ledger.jsonl" "$workspace/ledger-restored/erasure-ledger.jsonl" grep -q 'erasure ledger restore: PASS' "$workspace/ledger-restore.log" ! grep -q 'synthetic customer marker' "$workspace"/*.log -echo 'backup encryption: PASS roundtrip wrong-key tamper encrypted-ledger-archive no-host-node no-plaintext-output secret-safe-errors' +echo 'backup encryption: PASS roundtrip wrong-key tamper encrypted-ledger-archive no-host-node reviewed-image fail-closed no-plaintext-output secret-safe-errors' diff --git a/scripts/ops/test-fixtures/docker-node-run.sh b/scripts/ops/test-fixtures/docker-node-run.sh index b65b34e..77288e4 100755 --- a/scripts/ops/test-fixtures/docker-node-run.sh +++ b/scripts/ops/test-fixtures/docker-node-run.sh @@ -1,6 +1,12 @@ #!/usr/bin/env bash set -euo pipefail +if [[ ${1:-} = image && ${2:-} = inspect ]]; then + [[ ${3:-} = --format && ${5:-} = wctm-privacy-ops:node24.20.0-alpine3.24-openssl3.5.9-r0 ]] || exit 99 + printf '%s\n' "${TEST_DOCKER_IMAGE_DETAILS:-$(printf 'sha256:%064d privacy-ops node' 0)}" + exit 0 +fi + [[ ${1:-} = run && " $* " = *' --pull=never '* && " $* " = *' --network=none '* && " $* " = *' --read-only '* ]] || exit 99 while (($#)); do @@ -12,7 +18,7 @@ while (($#)); do --entrypoint) [[ ${2:-} = node ]] || exit 99 shift 2 - [[ ${1:-} =~ ^node:24\.20\.0-alpine3\.24@sha256:[0-9a-f]{64}$ ]] || exit 99 + [[ ${1:-} =~ ^sha256:[0-9a-f]{64}$ ]] || exit 99 shift exec "$TEST_NODE" "$@" ;; *) exit 99 ;; diff --git a/scripts/ops/test-pilot-privacy.sh b/scripts/ops/test-pilot-privacy.sh index 5efee2d..5388191 100755 --- a/scripts/ops/test-pilot-privacy.sh +++ b/scripts/ops/test-pilot-privacy.sh @@ -16,7 +16,7 @@ cp scripts/ops/test-fixtures/rclone "$workspace/bin/rclone" cat >"$workspace/bin/docker" <<'SH' #!/usr/bin/env bash printf '%s\n' "$*" >>"$TEST_DOCKER_LOG" -if [[ ${1:-} = run ]]; then +if [[ ${1:-} = run || ( ${1:-} = image && ${2:-} = inspect ) ]]; then exec "$TEST_DOCKER_NODE_FIXTURE" "$@" fi previous='' diff --git a/scripts/ops/test-scheduled-backup.sh b/scripts/ops/test-scheduled-backup.sh index 1d06631..39b6902 100755 --- a/scripts/ops/test-scheduled-backup.sh +++ b/scripts/ops/test-scheduled-backup.sh @@ -6,8 +6,7 @@ export TEST_DOCKER_NODE_FIXTURE="$(pwd -P)/scripts/ops/test-fixtures/docker-node export TEST_NODE=$(command -v node) workspace=$(mktemp -d) trap 'chmod 0600 "$workspace/rclone.conf" "$workspace/.env" 2>/dev/null || true; rm -rf -- "$workspace"' EXIT -mkdir -p "$workspace/scripts/ops" "$workspace/bin" "$workspace/backups" "$workspace/backend" -cp backend/Dockerfile "$workspace/backend/Dockerfile" +mkdir -p "$workspace/scripts/ops" "$workspace/bin" "$workspace/backups" cp scripts/ops/scheduled-backup.sh scripts/ops/backup-postgres.sh \ scripts/ops/offsite-rclone.sh scripts/ops/offsite-retention.sh \ scripts/ops/decrypt-offsite.sh \ @@ -62,6 +61,9 @@ case "${1:-}" in run) exec "$TEST_DOCKER_NODE_FIXTURE" "$@" ;; + image) + exec "$TEST_DOCKER_NODE_FIXTURE" "$@" + ;; *) exit 99 ;; esac SH