#!/usr/bin/env bash set -euo pipefail # Fail-closed production handoff validator for Linux deployment. It deliberately # rejects the local lab, image tags, HTTP IdPs/Vaults and Object-Lock-free S3. # Usage: production-preflight.sh [path-to-casan-prod.env] SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/casan-paths.sh" ROOT="$CASAN_APP_ROOT" ENV_FILE="${1:-$ROOT/infra/production/casan-prod.env}" fail() { echo "PRODUCTION_PREFLIGHT_FAIL $*" >&2; exit 1; } pass() { echo "PASS: $*"; } [[ -f "$ENV_FILE" ]] || fail "missing_env_file path=$ENV_FILE" # Parse only dotenv assignments. Sourcing a deployment env file would execute # arbitrary shell code before the security checks themselves run. while IFS= read -r raw || [[ -n "$raw" ]]; do line="${raw#"${raw%%[![:space:]]*}"}" [[ -z "$line" || "$line" == \#* ]] && continue [[ "$line" =~ ^([A-Z0-9_]+)=(.*)$ ]] || fail "invalid_env_syntax" key="${BASH_REMATCH[1]}"; value="${BASH_REMATCH[2]}" case "$key" in CASAN_PUBLIC_FQDN|CASAN_CP_HTTPS_PORT|CASAN_CP_TLS_DIR|CASAN_CP_OAUTH_ENV|CASAN_CP_RUNTIME_ENV|CASAN_CP_VAULT_ENV|CASAN_CP_STATE_DIR|CASAN_CP_OUTPUT_DIR|CASAN_CP_API_IMAGE|CASAN_CP_UI_IMAGE|CASAN_S3_BUCKET|CASAN_S3_PREFIX|CASAN_S3_REGION|CASAN_S3_RETENTION_DAYS|CASAN_S3_KMS_KEY_ID) export "$key=$value" ;; *) fail "unexpected_env_key key=$key" ;; esac done < "$ENV_FILE" required=(CASAN_PUBLIC_FQDN CASAN_CP_TLS_DIR CASAN_CP_OAUTH_ENV CASAN_CP_RUNTIME_ENV CASAN_CP_VAULT_ENV CASAN_CP_STATE_DIR CASAN_CP_OUTPUT_DIR CASAN_CP_API_IMAGE CASAN_CP_UI_IMAGE CASAN_S3_BUCKET CASAN_S3_REGION CASAN_S3_KMS_KEY_ID) for key in "${required[@]}"; do [[ -n "${!key:-}" ]] || fail "missing_env key=$key"; done case "$CASAN_PUBLIC_FQDN" in *localhost*|*127.0.0.1*|*example.com*|*replace-with*|*/*|[0-9]* ) fail "invalid_fqdn";; esac [[ "$CASAN_PUBLIC_FQDN" == *.* ]] || fail "fqdn_required" for image in "$CASAN_CP_API_IMAGE" "$CASAN_CP_UI_IMAGE"; do [[ "$image" =~ @sha256:[a-f0-9]{64}$ ]] || fail "image_must_be_digest_pinned image=$image" done pass "public FQDN and images are production-safe" for file in "$CASAN_CP_TLS_DIR/tls.crt" "$CASAN_CP_TLS_DIR/tls.key" "$CASAN_CP_OAUTH_ENV" "$CASAN_CP_RUNTIME_ENV" "$CASAN_CP_VAULT_ENV"; do [[ -s "$file" ]] || fail "missing_or_empty path=$file" done openssl x509 -in "$CASAN_CP_TLS_DIR/tls.crt" -noout >/dev/null || fail "invalid_tls_certificate" openssl x509 -in "$CASAN_CP_TLS_DIR/tls.crt" -noout -checkend 2592000 >/dev/null || fail "tls_expires_within_30_days" openssl x509 -in "$CASAN_CP_TLS_DIR/tls.crt" -noout -checkhost "$CASAN_PUBLIC_FQDN" >/dev/null || fail "tls_hostname_mismatch" cert_pub="$(openssl x509 -in "$CASAN_CP_TLS_DIR/tls.crt" -pubkey -noout | openssl pkey -pubin -outform DER | openssl dgst -sha256 | awk '{print $NF}')" key_pub="$(openssl pkey -in "$CASAN_CP_TLS_DIR/tls.key" -pubout -outform DER | openssl dgst -sha256 | awk '{print $NF}')" [[ "$cert_pub" == "$key_pub" ]] || fail "tls_key_does_not_match_certificate" pass "TLS certificate is valid for at least 30 days" value_of() { sed -n -E "s/^${1}=//p" "$CASAN_CP_OAUTH_ENV" | tail -1; } for key in OAUTH2_PROXY_PROVIDER OAUTH2_PROXY_OIDC_ISSUER_URL OAUTH2_PROXY_CLIENT_ID OAUTH2_PROXY_CLIENT_SECRET OAUTH2_PROXY_COOKIE_SECRET OAUTH2_PROXY_REDIRECT_URL OAUTH2_PROXY_OIDC_GROUPS_CLAIM; do value="$(value_of "$key")"; [[ -n "$value" ]] || fail "oidc_missing key=$key" case "$value" in *replace-with*|*localhost*|*127.0.0.1*|*example.com*|local-secret|root) fail "oidc_placeholder key=$key";; esac done [[ "$(value_of OAUTH2_PROXY_PROVIDER)" == oidc ]] || fail "oidc_provider_required" [[ "$(value_of OAUTH2_PROXY_OIDC_ISSUER_URL)" == https://* ]] || fail "oidc_issuer_https_required" [[ "$(value_of OAUTH2_PROXY_REDIRECT_URL)" == "https://$CASAN_PUBLIC_FQDN/oauth2/callback" ]] || fail "oidc_redirect_mismatch" [[ "$(value_of OAUTH2_PROXY_COOKIE_SECURE)" == true ]] || fail "oidc_secure_cookie_required" pass "enterprise OIDC configuration" vault_addr="$(sed -n -E 's/^VAULT_ADDR=//p' "$CASAN_CP_VAULT_ENV" | tail -1)" vault_token="$(sed -n -E 's/^VAULT_TOKEN=//p' "$CASAN_CP_VAULT_ENV" | tail -1)" vault_cacert="$(sed -n -E 's/^VAULT_CACERT=//p' "$CASAN_CP_VAULT_ENV" | tail -1)" [[ "$vault_addr" == https://* ]] || fail "vault_https_required" case "$vault_token" in ''|root|*replace-with*|*dev*) fail "vault_short_lived_token_required";; esac [[ -s "$vault_cacert" ]] || fail "vault_ca_required" vault_lookup="$(curl --fail --silent --show-error --connect-timeout 5 --cacert "$vault_cacert" -H "X-Vault-Token: $vault_token" "$vault_addr/v1/auth/token/lookup-self")" \ || fail "vault_token_lookup_failed" python3 - "$vault_lookup" <<'PY' || fail "vault_token_not_short_lived_or_privileged" import json, sys data = json.loads(sys.argv[1]).get("data", {}) ttl = int(data.get("ttl", 0)) policies = set(data.get("policies", [])) if not 0 < ttl <= 3600 or "root" in policies: raise SystemExit(1) PY pass "Vault HTTPS endpoint and short-lived non-root token" [[ -d "$CASAN_CP_STATE_DIR" && -d "$CASAN_CP_OUTPUT_DIR" ]] || fail "state_or_output_directory_missing" CASAN_PROFILE=prod CASAN_S3_BUCKET="$CASAN_S3_BUCKET" CASAN_S3_REGION="$CASAN_S3_REGION" \ CASAN_S3_PREFIX="${CASAN_S3_PREFIX:-audit-anchors}" CASAN_S3_RETENTION_DAYS="${CASAN_S3_RETENTION_DAYS:-365}" \ CASAN_S3_KMS_KEY_ID="$CASAN_S3_KMS_KEY_ID" \ "$SCRIPT_DIR/audit-ship-s3.sh" "$CASAN_CP_STATE_DIR/logs/audit/audit-head.txt" >/dev/null \ || fail "s3_object_lock_anchor_write_failed" pass "S3 Object Lock compliance anchor write" COMPOSE="$ROOT/docker-compose.control-panel.yml" CASAN_CP_TLS_DIR="$CASAN_CP_TLS_DIR" CASAN_CP_OAUTH_ENV="$CASAN_CP_OAUTH_ENV" \ CASAN_CP_RUNTIME_ENV="$CASAN_CP_RUNTIME_ENV" CASAN_CP_VAULT_ENV="$CASAN_CP_VAULT_ENV" \ CASAN_CP_STATE_DIR="$CASAN_CP_STATE_DIR" CASAN_CP_OUTPUT_DIR="$CASAN_CP_OUTPUT_DIR" \ CASAN_CP_API_IMAGE="$CASAN_CP_API_IMAGE" CASAN_CP_UI_IMAGE="$CASAN_CP_UI_IMAGE" \ docker compose -f "$COMPOSE" config >/dev/null || fail "compose_config_invalid" pass "production compose config" tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT cp "$CASAN_CP_TLS_DIR/tls.crt" "$tmp/tls.crt" cp "$CASAN_CP_TLS_DIR/tls.key" "$tmp/tls.key" docker run --rm -v "$ROOT/nginx/control-panel.conf:/etc/nginx/conf.d/default.conf:ro" -v "$tmp:/etc/nginx/tls:ro" nginx:1.27-alpine nginx -t >/dev/null \ || fail "nginx_config_invalid" pass "nginx TLS/auth proxy configuration" echo "PRODUCTION_PREFLIGHT_PASS fqdn=$CASAN_PUBLIC_FQDN oidc=true vault=true s3_object_lock=true images=true"