diff --git a/.env.example b/.env.example index 8481562..b2fc3f2 100644 --- a/.env.example +++ b/.env.example @@ -152,9 +152,10 @@ PUSH_HTTP_RECEIVE_TIMEOUT_MS= PUSH_HTTP_CONNECT_TIMEOUT_MS= PUSH_HTTP_RETRY_DELAY_MS= -# Direct browser Web Push. Generate one VAPID key pair per environment and -# keep the private key only in that environment's .env. The subject must be a -# mailto: or HTTPS contact owned by the operator. +# Direct browser Web Push. Generate one VAPID key pair per environment with +# scripts/generate-vapid-env.sh and keep the private key only in that +# environment's .env. The subject must be a mailto: or HTTPS contact owned by +# the operator. WEB_PUSH_VAPID_PUBLIC_KEY= WEB_PUSH_VAPID_PRIVATE_KEY= WEB_PUSH_VAPID_SUBJECT= diff --git a/docs/operations.md b/docs/operations.md index 5f88f3d..3f656bd 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -213,6 +213,9 @@ Provider downloads can be imported into that same file without placing secrets on a command line or printing them: ```bash +./scripts/generate-vapid-env.sh \ + .env mailto:contact@YOUR_DOMAIN + chmod 600 /secure/downloads/google-oauth-client.json ./scripts/import-google-oauth-client.sh \ .env /secure/downloads/google-oauth-client.json @@ -225,10 +228,19 @@ chmod 600 /secure/downloads/fcm-service-account.json .env /secure/downloads/fcm-service-account.json ``` +The VAPID helper runs the exact locked `web_push_elixir` generator in an +isolated, network-disabled container, imports the result atomically, removes +its one-run image tag and temporary files, and never prints either key. It +refuses to replace an existing VAPID identity because an unplanned rotation +invalidates existing browser subscriptions. + The importers validate the exact OAuth callback, Android package, Firebase project relationship, and required service-account fields before atomically replacing existing keys. They preserve mode `0600` and never create another -permanent environment file. OAuth and service-account downloads still contain +permanent environment file. Provider and initializer values that cannot be +represented as one unquoted Compose `.env` line are rejected before mutation. +Literal dollar signs are stored as `$$`, which Compose resolves back to one +`$` inside the container. OAuth and service-account downloads still contain private credentials after import; deliberately move them to protected backup storage or remove them after verification. diff --git a/scripts/generate-vapid-env.sh b/scripts/generate-vapid-env.sh new file mode 100755 index 0000000..a288bbb --- /dev/null +++ b/scripts/generate-vapid-env.sh @@ -0,0 +1,161 @@ +#!/bin/sh +set -eu +umask 077 + +if [ "$#" -ne 2 ]; then + echo "Usage: $0 ENV_FILE VAPID_SUBJECT" >&2 + exit 1 +fi + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +env_file=$1 +subject=$2 + +if [ ! -f "$env_file" ]; then + echo "Environment file does not exist: $env_file" >&2 + exit 1 +fi + +if [ "$(stat -c '%a' "$env_file")" != 600 ]; then + echo "Environment file must have mode 0600: $env_file" >&2 + exit 1 +fi + +case "$subject" in + mailto:?* | https://?*) ;; + *) + echo "VAPID_SUBJECT must be a non-empty mailto: or https:// URI without whitespace." >&2 + exit 1 + ;; +esac + +case "$subject" in + *' +'*) + echo "VAPID_SUBJECT must be a non-empty mailto: or https:// URI without whitespace." >&2 + exit 1 + ;; +esac + +if printf '%s' "$subject" | LC_ALL=C grep -q '[[:space:]]'; then + echo "VAPID_SUBJECT must be a non-empty mailto: or https:// URI without whitespace." >&2 + exit 1 +fi + +for command in awk chmod date docker grep mktemp rm stat; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "Required command is unavailable: $command" >&2 + exit 1 + fi +done + +for key in \ + WEB_PUSH_VAPID_PUBLIC_KEY \ + WEB_PUSH_VAPID_PRIVATE_KEY \ + WEB_PUSH_VAPID_SUBJECT; do + key_count=$( + awk -F= -v key="$key" '$1 == key { count++ } END { print count + 0 }' \ + "$env_file" + ) + if [ "$key_count" -ne 1 ]; then + echo "Environment must contain exactly one $key entry before VAPID generation." >&2 + exit 1 + fi + + existing_value=$( + awk -F= -v key="$key" ' + $1 == key { + print substr($0, index($0, "=") + 1) + exit + } + ' "$env_file" + ) + if [ -n "$existing_value" ]; then + echo "Refusing to rotate an existing VAPID identity: $key is already configured." >&2 + exit 1 + fi +done +unset existing_value + +env_dir=$(CDPATH='' cd -- "$(dirname -- "$env_file")" && pwd) +temporary_dir=$(mktemp -d "$env_dir/.vapid-generation.XXXXXX") +chmod 700 "$temporary_dir" +raw_keys="$temporary_dir/generated.txt" +values_file="$temporary_dir/values.env" +generator_image=${WNH_VAPID_GENERATOR_IMAGE:-} +owned_image=false +run_id="$(date -u +%Y%m%d%H%M%S)-$$" +generator_container="wnh-vapid-tool-$run_id" + +cleanup() { + trap - EXIT HUP INT TERM + rm -rf "$temporary_dir" + docker rm --force "$generator_container" >/dev/null 2>&1 || true + if [ "$owned_image" = true ]; then + docker image rm "$generator_image" >/dev/null 2>&1 || true + fi +} + +cleanup_on_exit() { + exit_status=$? + cleanup + exit "$exit_status" +} + +trap cleanup_on_exit EXIT +trap 'cleanup; exit 129' HUP +trap 'cleanup; exit 130' INT +trap 'cleanup; exit 143' TERM + +if [ -z "$generator_image" ]; then + generator_image="who-need-help:vapid-tool-$run_id" + owned_image=true + docker build --quiet --target test --tag "$generator_image" "$ROOT" >/dev/null +fi + +docker image inspect "$generator_image" >/dev/null +docker run --rm \ + --name "$generator_container" \ + --network none \ + --read-only \ + --tmpfs /tmp:rw,noexec,nosuid,size=16m \ + --entrypoint mix \ + "$generator_image" \ + generate.vapid.keys >"$raw_keys" +chmod 600 "$raw_keys" + +if ! awk -F'"' -v subject="$subject" ' + /^[[:space:]]*vapid_private_key:/ { + private_count++ + private_key = $2 + } + /^[[:space:]]*vapid_public_key:/ { + public_count++ + public_key = $2 + } + END { + valid_private = private_key ~ /^[A-Za-z0-9_-]+$/ + valid_public = public_key ~ /^[A-Za-z0-9_-]+$/ + + if (private_count != 1 || + public_count != 1 || + !valid_private || + !valid_public || + private_key == public_key) { + exit 40 + } + + print "WEB_PUSH_VAPID_PUBLIC_KEY=" public_key + print "WEB_PUSH_VAPID_PRIVATE_KEY=" private_key + print "WEB_PUSH_VAPID_SUBJECT=" subject + } +' "$raw_keys" >"$values_file"; then + echo "The pinned web_push_elixir generator returned an unexpected key format." >&2 + exit 1 +fi +chmod 600 "$values_file" + +"$ROOT/scripts/set-env-values.sh" "$env_file" "$values_file" >/dev/null + +echo "Generated a new environment-specific VAPID identity without printing its keys." +echo "Updated the single mode-0600 environment file: $env_file" diff --git a/scripts/import-fcm-service-account.sh b/scripts/import-fcm-service-account.sh index 11364b9..a2a7559 100755 --- a/scripts/import-fcm-service-account.sh +++ b/scripts/import-fcm-service-account.sh @@ -26,8 +26,10 @@ esac if ! jq --exit-status ' .type == "service_account" - and (.project_id | type == "string" and length > 0) - and (.client_email | type == "string" and length > 0) + and (.project_id + | type == "string" and length > 0 and test("^[^\r\n]+$")) + and (.client_email + | type == "string" and length > 0 and test("^[^\r\n]+$")) and (.private_key | type == "string" and length > 0) ' "$service_account_file" >/dev/null; then echo "FCM service-account JSON is incomplete." >&2 diff --git a/scripts/import-firebase-android-config.sh b/scripts/import-firebase-android-config.sh index cf2d6b6..fcd875c 100755 --- a/scripts/import-firebase-android-config.sh +++ b/scripts/import-firebase-android-config.sh @@ -38,12 +38,16 @@ if ! jq --exit-status --arg package "$package_name" ' | select(.client_info.android_client_info.package_name == $package) ] as $clients | ($clients | length == 1) - and (.project_info.project_id | type == "string" and length > 0) - and (.project_info.project_number | type == "string" and length > 0) - and ($clients[0].client_info.mobilesdk_app_id | type == "string" and length > 0) + and (.project_info.project_id + | type == "string" and length > 0 and test("^[^\r\n]+$")) + and (.project_info.project_number + | type == "string" and length > 0 and test("^[^\r\n]+$")) + and ($clients[0].client_info.mobilesdk_app_id + | type == "string" and length > 0 and test("^[^\r\n]+$")) and ($clients[0].client_info.mobilesdk_app_id | startswith("1:" + $project_number + ":android:")) - and ($clients[0].api_key[0].current_key | type == "string" and length > 0) + and ($clients[0].api_key[0].current_key + | type == "string" and length > 0 and test("^[^\r\n]+$")) ' "$client_file" >/dev/null; then echo "Firebase Android configuration does not contain exactly one complete client for package $package_name." >&2 exit 1 diff --git a/scripts/init-production-env.sh b/scripts/init-production-env.sh index 17efdc3..c8157be 100755 --- a/scripts/init-production-env.sh +++ b/scripts/init-production-env.sh @@ -10,6 +10,31 @@ usage() { echo "Usage: $0 DOMAIN [OUTPUT_FILE]" >&2 } +require_single_line_env_value() { + value_name=$1 + value=$2 + + case "$value" in + *' +'*) + echo "$value_name must not contain control characters because .env stores one value per line." >&2 + exit 1 + ;; + esac + + if printf '%s' "$value" | LC_ALL=C grep -q '[[:cntrl:]]'; then + echo "$value_name must not contain control characters because .env stores one value per line." >&2 + exit 1 + fi + + case "$value" in + ' '* | *' ' | \"* | \'* | *' #'*) + echo "$value_name cannot be represented safely as an unquoted Compose .env value." >&2 + exit 1 + ;; + esac +} + if [ -z "$domain" ]; then usage exit 1 @@ -69,6 +94,30 @@ test_upstream=${PRODUCTION_TEST_UPSTREAM:-who-need-help-test:4000} edge_compose_project_name=${PRODUCTION_EDGE_COMPOSE_PROJECT_NAME:-who_need_help_edge} git_sha=$(git -C "$ROOT" rev-parse --short=12 HEAD) +require_single_line_env_value PRODUCTION_DATABASE_MODE "$database_mode" +require_single_line_env_value PRODUCTION_APP_TOPOLOGY "$app_topology" +require_single_line_env_value PRODUCTION_COMPOSE_PROJECT_NAME "$compose_project_name" +require_single_line_env_value PRODUCTION_PUBLIC_EDGE_ENABLED "$public_edge_enabled" +require_single_line_env_value PRODUCTION_PUBLIC_EDGE_NETWORK "$public_edge_network" +require_single_line_env_value PRODUCTION_PUBLIC_UPSTREAM_NAME "$public_upstream_name" +require_single_line_env_value PRODUCTION_CODEX_SESSION_ID "$codex_session_id" +require_single_line_env_value PRODUCTION_GOOGLE_OAUTH_CLIENT_ID "$google_oauth_client_id" +require_single_line_env_value PRODUCTION_GOOGLE_OAUTH_CLIENT_SECRET "$google_oauth_client_secret" +require_single_line_env_value PRODUCTION_WEB_PUSH_VAPID_PUBLIC_KEY "$web_push_vapid_public_key" +require_single_line_env_value PRODUCTION_WEB_PUSH_VAPID_PRIVATE_KEY "$web_push_vapid_private_key" +require_single_line_env_value PRODUCTION_WEB_PUSH_VAPID_SUBJECT "$web_push_vapid_subject" +require_single_line_env_value PRODUCTION_WNH_FIREBASE_APPLICATION_ID "$firebase_application_id" +require_single_line_env_value PRODUCTION_WNH_FIREBASE_API_KEY "$firebase_api_key" +require_single_line_env_value PRODUCTION_WNH_FIREBASE_PROJECT_ID "$firebase_project_id" +require_single_line_env_value PRODUCTION_WNH_FIREBASE_GCM_SENDER_ID "$firebase_sender_id" +require_single_line_env_value PRODUCTION_FCM_PROJECT_ID "$fcm_project_id" +require_single_line_env_value PRODUCTION_FCM_SERVICE_ACCOUNT_JSON_BASE64 "$fcm_service_account_json_base64" +require_single_line_env_value PRODUCTION_ANDROID_APP_LINKS_PACKAGE_NAME "$android_app_links_package_name" +require_single_line_env_value PRODUCTION_ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS "$android_app_links_fingerprints" +require_single_line_env_value PRODUCTION_TEST_DOMAIN "$test_domain" +require_single_line_env_value PRODUCTION_TEST_UPSTREAM "$test_upstream" +require_single_line_env_value PRODUCTION_EDGE_COMPOSE_PROJECT_NAME "$edge_compose_project_name" + if [ -z "$codex_session_id" ]; then echo "PRODUCTION_CODEX_SESSION_ID is required for the Build Week feedback page." >&2 exit 1 @@ -87,6 +136,7 @@ if { [ -n "$android_app_links_package_name" ] || [ -n "$android_app_links_finger fi firebase_values="$firebase_application_id $firebase_api_key $firebase_project_id $firebase_sender_id" +firebase_configured=false if printf '%s\n' "$firebase_values" | grep -q '[^[:space:]]'; then for value in "$firebase_application_id" "$firebase_api_key" \ "$firebase_project_id" "$firebase_sender_id"; do @@ -95,6 +145,15 @@ if printf '%s\n' "$firebase_values" | grep -q '[^[:space:]]'; then exit 1 } done + + firebase_prefix="1:$firebase_sender_id:android:" + if ! printf '%s\n' "$firebase_sender_id" | grep -Eq '^[0-9]+$' || + [ "${firebase_application_id#"$firebase_prefix"}" = "$firebase_application_id" ] || + [ -z "${firebase_application_id#"$firebase_prefix"}" ]; then + echo "Production Firebase application ID must belong to the configured numeric sender/project number." >&2 + exit 1 + fi + firebase_configured=true fi vapid_values="$web_push_vapid_public_key $web_push_vapid_private_key $web_push_vapid_subject" @@ -106,6 +165,14 @@ if printf '%s\n' "$vapid_values" | grep -q '[^[:space:]]'; then exit 1 } done + + case "$web_push_vapid_subject" in + mailto:?* | https://?*) ;; + *) + echo "Production WEB_PUSH_VAPID_SUBJECT must be a non-empty mailto: or https:// URI." >&2 + exit 1 + ;; + esac fi if { [ -n "$fcm_project_id" ] || [ -n "$fcm_service_account_json_base64" ]; } && @@ -114,6 +181,7 @@ if { [ -n "$fcm_project_id" ] || [ -n "$fcm_service_account_json_base64" ]; } && exit 1 fi +fcm_credential_project_id= if [ -n "$fcm_service_account_json_base64" ]; then for command in base64 jq; do command -v "$command" >/dev/null 2>&1 || { @@ -121,17 +189,38 @@ if [ -n "$fcm_service_account_json_base64" ]; then exit 1 } done - if ! printf '%s' "$fcm_service_account_json_base64" | + if ! fcm_credential_project_id=$(printf '%s' "$fcm_service_account_json_base64" | base64 --decode 2>/dev/null | - jq -e ' - .type == "service_account" and - (.project_id | type == "string" and length > 0) and - (.client_email | type == "string" and length > 0) and - (.private_key | type == "string" and length > 0) - ' >/dev/null 2>&1; then + jq -er ' + . as $credential + | ( + ($credential.type == "service_account") and + ($credential.project_id | type == "string" and length > 0) and + ($credential.client_email | type == "string" and length > 0) and + ($credential.private_key | type == "string" and length > 0) + ) + | if . then $credential.project_id else error("incomplete service account") end + ' 2>/dev/null); then echo "Production FCM credential is not a complete service-account JSON document." >&2 exit 1 fi + + if [ "$fcm_credential_project_id" != "$fcm_project_id" ]; then + echo "Production FCM service-account project must match PRODUCTION_FCM_PROJECT_ID." >&2 + exit 1 + fi + + if [ "$firebase_configured" = true ] && + [ "$fcm_project_id" != "$firebase_project_id" ]; then + echo "Production Firebase Android client and FCM service account must use the same project." >&2 + exit 1 + fi +fi + +if [ -n "$android_app_links_package_name" ] && + [ "$android_app_links_package_name" != org.whoneedhelp.mobile ]; then + echo "Production Android App Links package must be org.whoneedhelp.mobile." >&2 + exit 1 fi case "$compose_project_name" in @@ -203,6 +292,22 @@ smtp_ssl=${PRODUCTION_SMTP_SSL:-false} email_from_address=${PRODUCTION_EMAIL_FROM_ADDRESS:-"contact@$domain"} support_inbox_address=${PRODUCTION_SUPPORT_INBOX_ADDRESS:-} +require_single_line_env_value PRODUCTION_DATABASE_URL "$database_url" +require_single_line_env_value PRODUCTION_DATABASE_SOCKET_DIR "$database_socket_dir" +require_single_line_env_value PRODUCTION_HTTP_BIND_ADDRESS "$http_bind_address" +require_single_line_env_value PRODUCTION_HTTP_PORT "$http_port" +require_single_line_env_value PRODUCTION_TRAEFIK_TRUSTED_IPS "$trusted_proxy_ips" +require_single_line_env_value PRODUCTION_EMAIL_DELIVERY_PROVIDER "$email_delivery_provider" +require_single_line_env_value PRODUCTION_SMTP_RELAY "$smtp_relay" +require_single_line_env_value PRODUCTION_SMTP_PORT "$smtp_port" +require_single_line_env_value PRODUCTION_SMTP_USERNAME "$smtp_username" +require_single_line_env_value PRODUCTION_SMTP_PASSWORD "$smtp_password" +require_single_line_env_value PRODUCTION_SMTP_AUTH "$smtp_auth" +require_single_line_env_value PRODUCTION_SMTP_TLS "$smtp_tls" +require_single_line_env_value PRODUCTION_SMTP_SSL "$smtp_ssl" +require_single_line_env_value PRODUCTION_EMAIL_FROM_ADDRESS "$email_from_address" +require_single_line_env_value PRODUCTION_SUPPORT_INBOX_ADDRESS "$support_inbox_address" + [ "$email_delivery_provider" = smtp ] || { echo "PRODUCTION_EMAIL_DELIVERY_PROVIDER must be smtp." >&2 exit 1 @@ -326,6 +431,10 @@ TEST_UPSTREAM_VALUE=$test_upstream \ replacement["WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS"] = "" replacement["WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS"] = "" replacement["CODEX_SESSION_ID"] = ENVIRON["CODEX_SESSION_ID_VALUE"] + + for (key in replacement) { + gsub(/\$/, "$$", replacement[key]) + } } { separator = index($0, "=") @@ -347,6 +456,7 @@ unset postgres_password secret_key_base handover_secret release_cookie metrics_t unset smtp_password unset google_oauth_client_secret unset web_push_vapid_private_key firebase_api_key fcm_service_account_json_base64 +unset fcm_credential_project_id unset android_app_links_fingerprints echo "Generated independent deployment secrets without printing them." diff --git a/scripts/init-test-env.sh b/scripts/init-test-env.sh index a01a3c4..f798130 100755 --- a/scripts/init-test-env.sh +++ b/scripts/init-test-env.sh @@ -11,6 +11,27 @@ if [[ -z "$domain" ]]; then exit 1 fi +require_single_line_env_value() { + local value_name=$1 + local value=$2 + + if [[ "$value" == *$'\n'* ]]; then + echo "$value_name must not contain control characters because .env stores one value per line." >&2 + exit 1 + fi + + if printf '%s' "$value" | LC_ALL=C grep -q '[[:cntrl:]]'; then + echo "$value_name must not contain control characters because .env stores one value per line." >&2 + exit 1 + fi + + if [[ "$value" == ' '* || "$value" == *' ' || + "$value" == \"* || "$value" == \'* || "$value" == *' #'* ]]; then + echo "$value_name cannot be represented safely as an unquoted Compose .env value." >&2 + exit 1 + fi +} + if [[ ! "$domain" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$ ]]; then echo "DOMAIN must be a lowercase ASCII DNS hostname without a scheme, port, or path." >&2 exit 1 @@ -20,7 +41,7 @@ if [[ "$target" != /* ]]; then target="$ROOT/$target" fi -for command in awk docker git mktemp openssl stat; do +for command in awk docker git grep mktemp openssl stat; do command -v "$command" >/dev/null 2>&1 || { echo "Required command is unavailable: $command" >&2 exit 1 @@ -67,6 +88,29 @@ android_app_links_fingerprints=${TEST_ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS support_inbox_address=${TEST_SUPPORT_INBOX_ADDRESS:-} git_sha=$(git -C "$ROOT" rev-parse --short=12 HEAD) +require_single_line_env_value TEST_COMPOSE_PROJECT_NAME "$compose_project_name" +require_single_line_env_value TEST_PUBLIC_EDGE_NETWORK "$public_edge_network" +require_single_line_env_value TEST_PUBLIC_UPSTREAM_NAME "$public_upstream_name" +require_single_line_env_value TEST_HTTP_BIND_ADDRESS "$http_bind_address" +require_single_line_env_value TEST_HTTP_PORT "$http_port" +require_single_line_env_value TEST_MAILPIT_BIND_ADDRESS "$mailpit_bind_address" +require_single_line_env_value TEST_MAILPIT_PORT "$mailpit_port" +require_single_line_env_value TEST_CODEX_SESSION_ID "$codex_session_id" +require_single_line_env_value TEST_GOOGLE_OAUTH_CLIENT_ID "$google_oauth_client_id" +require_single_line_env_value TEST_GOOGLE_OAUTH_CLIENT_SECRET "$google_oauth_client_secret" +require_single_line_env_value TEST_WEB_PUSH_VAPID_PUBLIC_KEY "$web_push_vapid_public_key" +require_single_line_env_value TEST_WEB_PUSH_VAPID_PRIVATE_KEY "$web_push_vapid_private_key" +require_single_line_env_value TEST_WEB_PUSH_VAPID_SUBJECT "$web_push_vapid_subject" +require_single_line_env_value TEST_WNH_FIREBASE_APPLICATION_ID "$firebase_application_id" +require_single_line_env_value TEST_WNH_FIREBASE_API_KEY "$firebase_api_key" +require_single_line_env_value TEST_WNH_FIREBASE_PROJECT_ID "$firebase_project_id" +require_single_line_env_value TEST_WNH_FIREBASE_GCM_SENDER_ID "$firebase_sender_id" +require_single_line_env_value TEST_FCM_PROJECT_ID "$fcm_project_id" +require_single_line_env_value TEST_FCM_SERVICE_ACCOUNT_JSON_BASE64 "$fcm_service_account_json_base64" +require_single_line_env_value TEST_ANDROID_APP_LINKS_PACKAGE_NAME "$android_app_links_package_name" +require_single_line_env_value TEST_ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS "$android_app_links_fingerprints" +require_single_line_env_value TEST_SUPPORT_INBOX_ADDRESS "$support_inbox_address" + [[ "$compose_project_name" =~ ^[a-zA-Z0-9_-]+$ ]] || { echo "TEST_COMPOSE_PROJECT_NAME contains unsupported characters." >&2 exit 1 @@ -98,6 +142,7 @@ if [[ -n "$android_app_links_package_name" || -n "$android_app_links_fingerprint fi firebase_values="$firebase_application_id $firebase_api_key $firebase_project_id $firebase_sender_id" +firebase_configured=false if grep -q '[^[:space:]]' <<<"$firebase_values"; then for value in "$firebase_application_id" "$firebase_api_key" \ "$firebase_project_id" "$firebase_sender_id"; do @@ -106,6 +151,14 @@ if grep -q '[^[:space:]]' <<<"$firebase_values"; then exit 1 } done + + firebase_prefix="1:$firebase_sender_id:android:" + if [[ ! "$firebase_sender_id" =~ ^[0-9]+$ || + "$firebase_application_id" != "$firebase_prefix"?* ]]; then + echo "Test Firebase application ID must belong to the configured numeric sender/project number." >&2 + exit 1 + fi + firebase_configured=true fi vapid_values="$web_push_vapid_public_key $web_push_vapid_private_key $web_push_vapid_subject" @@ -117,6 +170,12 @@ if grep -q '[^[:space:]]' <<<"$vapid_values"; then exit 1 } done + + if [[ "$web_push_vapid_subject" != mailto:?* && + "$web_push_vapid_subject" != https://?* ]]; then + echo "Test WEB_PUSH_VAPID_SUBJECT must be a non-empty mailto: or https:// URI." >&2 + exit 1 + fi fi if [[ (-n "$fcm_project_id" || -n "$fcm_service_account_json_base64") && @@ -125,6 +184,7 @@ if [[ (-n "$fcm_project_id" || -n "$fcm_service_account_json_base64") && exit 1 fi +fcm_credential_project_id= if [[ -n "$fcm_service_account_json_base64" ]]; then for command in base64 jq; do command -v "$command" >/dev/null 2>&1 || { @@ -132,17 +192,38 @@ if [[ -n "$fcm_service_account_json_base64" ]]; then exit 1 } done - if ! printf '%s' "$fcm_service_account_json_base64" | + if ! fcm_credential_project_id=$(printf '%s' "$fcm_service_account_json_base64" | base64 --decode 2>/dev/null | - jq -e ' - .type == "service_account" and - (.project_id | type == "string" and length > 0) and - (.client_email | type == "string" and length > 0) and - (.private_key | type == "string" and length > 0) - ' >/dev/null 2>&1; then + jq -er ' + . as $credential + | ( + ($credential.type == "service_account") and + ($credential.project_id | type == "string" and length > 0) and + ($credential.client_email | type == "string" and length > 0) and + ($credential.private_key | type == "string" and length > 0) + ) + | if . then $credential.project_id else error("incomplete service account") end + ' 2>/dev/null); then echo "Test FCM credential is not a complete service-account JSON document." >&2 exit 1 fi + + if [[ "$fcm_credential_project_id" != "$fcm_project_id" ]]; then + echo "Test FCM service-account project must match TEST_FCM_PROJECT_ID." >&2 + exit 1 + fi + + if [[ "$firebase_configured" == true && + "$fcm_project_id" != "$firebase_project_id" ]]; then + echo "Test Firebase Android client and FCM service account must use the same project." >&2 + exit 1 + fi +fi + +if [[ -n "$android_app_links_package_name" && + "$android_app_links_package_name" != org.whoneedhelp.mobile.staging ]]; then + echo "Test Android App Links package must be org.whoneedhelp.mobile.staging." >&2 + exit 1 fi for pair in "HTTP:$http_port" "MAILPIT:$mailpit_port"; do @@ -267,6 +348,10 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \ replacement["WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS"] = "" replacement["WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS"] = "who-need-help-staging" replacement["CODEX_SESSION_ID"] = ENVIRON["CODEX_SESSION_ID_VALUE"] + + for (key in replacement) { + gsub(/\$/, "$$", replacement[key]) + } } { separator = index($0, "=") @@ -282,6 +367,7 @@ trap - EXIT HUP INT TERM unset postgres_password secret_key_base handover_secret release_cookie metrics_token unset google_oauth_client_secret unset web_push_vapid_private_key firebase_api_key fcm_service_account_json_base64 +unset fcm_credential_project_id unset android_app_links_fingerprints "$ROOT/scripts/compose.sh" "$target" config --quiet diff --git a/scripts/quality.sh b/scripts/quality.sh index 938ec8f..428111e 100755 --- a/scripts/quality.sh +++ b/scripts/quality.sh @@ -105,7 +105,8 @@ chmod 600 "$credential_env" credential_values="$scan_dir/credential-values" printf '%s\n' \ 'GOOGLE_OAUTH_CLIENT_ID=quality-imported-client' \ - 'GOOGLE_OAUTH_CLIENT_SECRET=quality-imported-secret' >"$credential_values" + 'GOOGLE_OAUTH_CLIENT_SECRET=quality-imported-secret' \ + "SMTP_PASSWORD=quality\$literal" >"$credential_values" chmod 600 "$credential_values" credential_output=$( ./scripts/set-env-values.sh "$credential_env" "$credential_values" @@ -117,6 +118,32 @@ fi test "$(stat -c '%a' "$credential_env")" = 600 grep -Fx 'GOOGLE_OAUTH_CLIENT_ID=quality-imported-client' "$credential_env" >/dev/null grep -Fx 'GOOGLE_OAUTH_CLIENT_SECRET=quality-imported-secret' "$credential_env" >/dev/null +grep -Fx "SMTP_PASSWORD=quality\$\$literal" "$credential_env" >/dev/null + +compose_env_probe="$scan_dir/compose-env-probe.yaml" +printf '%s\n' \ + 'services:' \ + ' probe:' \ + " image: $SHELLCHECK_IMAGE" \ + ' network_mode: none' \ + ' entrypoint: ["/usr/bin/env"]' \ + ' environment:' \ + " SMTP_PASSWORD: \${SMTP_PASSWORD}" \ + >"$compose_env_probe" +resolved_smtp_password=$( + docker compose \ + --project-name "$project" \ + --env-file "$credential_env" \ + --file "$compose_env_probe" \ + run --rm --no-deps probe | + awk -F= ' + $1 == "SMTP_PASSWORD" { + print substr($0, index($0, "=") + 1) + exit + } + ' +) +test "$resolved_smtp_password" = "quality\$literal" duplicate_env="$scan_dir/credentials-duplicate.env" cp "$credential_env" "$duplicate_env" @@ -212,6 +239,41 @@ if ./scripts/import-firebase-android-config.sh \ exit 1 fi +firebase_injected_client="$scan_dir/google-services-injected.json" +injected_firebase_project=$( + printf 'quality-development\nGOOGLE_OAUTH_CLIENT_SECRET=injected' +) +jq --null-input \ + --arg project_id "$injected_firebase_project" \ + '{ + project_info: { + project_number: "123456789", + project_id: $project_id + }, + client: [ + { + client_info: { + mobilesdk_app_id: "1:123456789:android:quality", + android_client_info: { + package_name: "org.whoneedhelp.mobile.staging" + } + }, + api_key: [ + { + current_key: "quality-firebase-api-key" + } + ] + } + ] + }' >"$firebase_injected_client" +credential_hash=$(sha256sum "$credential_env" | awk '{print $1}') +if ./scripts/import-firebase-android-config.sh \ + "$credential_env" "$firebase_injected_client" >/dev/null 2>&1; then + echo "Firebase importer accepted a line-breaking project ID." >&2 + exit 1 +fi +test "$(sha256sum "$credential_env" | awk '{print $1}')" = "$credential_hash" + echo "Checking Android environment isolation" android_fingerprint=D7:C4:F1:12:4D:F4:68:E5:B3:54:DE:D8:96:E8:01:51:29:41:F1:8A:71:0C:18:B0:E7:98:AA:2B:81:DA:11:DF android_env="$scan_dir/android-development.env" @@ -277,6 +339,28 @@ printf '%s' "$credential_fcm_base64" | '.project_id == "quality-development" and .private_key == "quality-private-key"' \ >/dev/null +fcm_injected_service_account="$scan_dir/fcm-service-account-injected.json" +injected_fcm_project=$( + printf 'quality-development\nGOOGLE_OAUTH_CLIENT_SECRET=injected' +) +jq --null-input \ + --arg project_id "$injected_fcm_project" \ + '{ + type: "service_account", + project_id: $project_id, + client_email: "quality-fcm@example.invalid", + private_key: "quality-private-key" + }' >"$fcm_injected_service_account" +chmod 600 "$fcm_injected_service_account" +credential_hash=$(sha256sum "$credential_env" | awk '{print $1}') +if ./scripts/import-fcm-service-account.sh \ + "$credential_env" "$fcm_injected_service_account" >/dev/null 2>&1; then + echo "FCM importer accepted a line-breaking project ID." >&2 + exit 1 +fi +test "$(sha256sum "$credential_env" | awk '{print $1}')" = "$credential_hash" +rm -f "$fcm_injected_service_account" + echo "Checking the existing load environment upgrade path" legacy_load_env="$scan_dir/legacy-load.env" printf '%s\n' \ @@ -304,6 +388,16 @@ quality_fcm_base64=$( '{"type":"service_account","project_id":"quality-production","client_email":"quality-fcm@quality-production.iam.gserviceaccount.com","private_key":"quality-private-key"}' | base64 -w 0 ) +quality_test_fcm_base64=$( + printf '%s' \ + '{"type":"service_account","project_id":"quality-test","client_email":"quality-fcm@quality-test.iam.gserviceaccount.com","private_key":"quality-private-key"}' | + base64 -w 0 +) +quality_other_fcm_base64=$( + printf '%s' \ + '{"type":"service_account","project_id":"quality-other","client_email":"quality-fcm@quality-other.iam.gserviceaccount.com","private_key":"quality-private-key"}' | + base64 -w 0 +) test_env="$scan_dir/test.env" if ./scripts/init-test-env.sh test.help.test \ "$scan_dir/test.missing-codex.env" >/dev/null 2>&1; then @@ -312,7 +406,16 @@ if ./scripts/init-test-env.sh test.help.test \ fi TEST_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ TEST_GOOGLE_OAUTH_CLIENT_ID=quality-test-client \ -TEST_GOOGLE_OAUTH_CLIENT_SECRET=quality-test-secret \ +TEST_GOOGLE_OAUTH_CLIENT_SECRET="quality-test\$secret" \ +TEST_WEB_PUSH_VAPID_PUBLIC_KEY=quality-test-vapid-public \ +TEST_WEB_PUSH_VAPID_PRIVATE_KEY=quality-test-vapid-private \ +TEST_WEB_PUSH_VAPID_SUBJECT=mailto:test@help.test \ +TEST_WNH_FIREBASE_APPLICATION_ID=1:123456789:android:quality-test \ +TEST_WNH_FIREBASE_API_KEY=quality-test-public-client-key \ +TEST_WNH_FIREBASE_PROJECT_ID=quality-test \ +TEST_WNH_FIREBASE_GCM_SENDER_ID=123456789 \ +TEST_FCM_PROJECT_ID=quality-test \ +TEST_FCM_SERVICE_ACCOUNT_JSON_BASE64="$quality_test_fcm_base64" \ TEST_ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile.staging \ TEST_ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=D7:C4:F1:12:4D:F4:68:E5:B3:54:DE:D8:96:E8:01:51:29:41:F1:8A:71:0C:18:B0:E7:98:AA:2B:81:DA:11:DF \ ./scripts/init-test-env.sh test.help.test "$test_env" >/dev/null @@ -332,6 +435,12 @@ grep -Fx 'EMAIL_DELIVERY_PROVIDER=smtp' "$test_env" >/dev/null grep -Fx 'SMTP_RELAY=mailpit' "$test_env" >/dev/null grep -Fx 'EMAIL_FROM_NAME="Who Need Help Test"' "$test_env" >/dev/null grep -Fx 'GOOGLE_OAUTH_CLIENT_ID=quality-test-client' "$test_env" >/dev/null +grep -Fx "GOOGLE_OAUTH_CLIENT_SECRET=quality-test\$\$secret" "$test_env" >/dev/null +grep -Fx 'WEB_PUSH_VAPID_SUBJECT=mailto:test@help.test' "$test_env" >/dev/null +grep -Fx 'WNH_FIREBASE_APPLICATION_ID=1:123456789:android:quality-test' "$test_env" >/dev/null +grep -Fx 'WNH_FIREBASE_PROJECT_ID=quality-test' "$test_env" >/dev/null +grep -Fx 'WNH_FIREBASE_GCM_SENDER_ID=123456789' "$test_env" >/dev/null +grep -Fx 'FCM_PROJECT_ID=quality-test' "$test_env" >/dev/null grep -Fx 'ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile.staging' "$test_env" >/dev/null grep -Fx 'ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=D7:C4:F1:12:4D:F4:68:E5:B3:54:DE:D8:96:E8:01:51:29:41:F1:8A:71:0C:18:B0:E7:98:AA:2B:81:DA:11:DF' "$test_env" >/dev/null ./scripts/validate-test-env.sh "$test_env" test.help.test >/dev/null @@ -347,6 +456,65 @@ if TEST_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ echo "Test environment initializer overwrote an existing file." >&2 exit 1 fi +if TEST_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ + TEST_WNH_FIREBASE_APPLICATION_ID=1:987654321:android:quality-test \ + TEST_WNH_FIREBASE_API_KEY=quality-test-public-client-key \ + TEST_WNH_FIREBASE_PROJECT_ID=quality-test \ + TEST_WNH_FIREBASE_GCM_SENDER_ID=123456789 \ + ./scripts/init-test-env.sh test.help.test \ + "$scan_dir/test.mismatched-firebase.env" >/dev/null 2>&1; then + echo "Test environment initializer accepted a Firebase application from another sender." >&2 + exit 1 +fi +if TEST_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ + TEST_FCM_PROJECT_ID=quality-test \ + TEST_FCM_SERVICE_ACCOUNT_JSON_BASE64="$quality_other_fcm_base64" \ + ./scripts/init-test-env.sh test.help.test \ + "$scan_dir/test.mismatched-fcm-credential.env" >/dev/null 2>&1; then + echo "Test environment initializer accepted an FCM service account from another project." >&2 + exit 1 +fi +if TEST_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ + TEST_WNH_FIREBASE_APPLICATION_ID=1:123456789:android:quality-test \ + TEST_WNH_FIREBASE_API_KEY=quality-test-public-client-key \ + TEST_WNH_FIREBASE_PROJECT_ID=quality-test \ + TEST_WNH_FIREBASE_GCM_SENDER_ID=123456789 \ + TEST_FCM_PROJECT_ID=quality-other \ + TEST_FCM_SERVICE_ACCOUNT_JSON_BASE64="$quality_other_fcm_base64" \ + ./scripts/init-test-env.sh test.help.test \ + "$scan_dir/test.mismatched-firebase-fcm.env" >/dev/null 2>&1; then + echo "Test environment initializer accepted different Firebase and FCM projects." >&2 + exit 1 +fi +if TEST_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ + TEST_ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile \ + TEST_ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=D7:C4:F1:12:4D:F4:68:E5:B3:54:DE:D8:96:E8:01:51:29:41:F1:8A:71:0C:18:B0:E7:98:AA:2B:81:DA:11:DF \ + ./scripts/init-test-env.sh test.help.test \ + "$scan_dir/test.production-package.env" >/dev/null 2>&1; then + echo "Test environment initializer accepted the production Android package." >&2 + exit 1 +fi +if TEST_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ + TEST_WEB_PUSH_VAPID_PUBLIC_KEY=quality-test-vapid-public \ + TEST_WEB_PUSH_VAPID_PRIVATE_KEY=quality-test-vapid-private \ + TEST_WEB_PUSH_VAPID_SUBJECT=ftp://help.test \ + ./scripts/init-test-env.sh test.help.test \ + "$scan_dir/test.invalid-vapid-subject.env" >/dev/null 2>&1; then + echo "Test environment initializer accepted an invalid VAPID subject." >&2 + exit 1 +fi +injected_test_secret=$( + printf 'quality-test-secret\nSMTP_PASSWORD=injected' +) +if TEST_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ + TEST_GOOGLE_OAUTH_CLIENT_ID=quality-test-client \ + TEST_GOOGLE_OAUTH_CLIENT_SECRET="$injected_test_secret" \ + ./scripts/init-test-env.sh test.help.test \ + "$scan_dir/test.injected-line.env" >/dev/null 2>&1; then + echo "Test environment initializer accepted a line-breaking credential." >&2 + exit 1 +fi +test ! -e "$scan_dir/test.injected-line.env" production_env="$scan_dir/production.env" missing_codex_env="$scan_dir/production.missing-codex.env" if PRODUCTION_TRAEFIK_TRUSTED_IPS=172.20.0.1/32 \ @@ -366,7 +534,7 @@ PRODUCTION_TRAEFIK_TRUSTED_IPS=172.20.0.1/32 \ PRODUCTION_SMTP_RELAY=smtp.help.test \ PRODUCTION_SMTP_PORT=587 \ PRODUCTION_SMTP_USERNAME=quality-user \ -PRODUCTION_SMTP_PASSWORD=quality-password \ +PRODUCTION_SMTP_PASSWORD="quality\$password" \ PRODUCTION_SMTP_AUTH=always \ PRODUCTION_SMTP_TLS=always \ PRODUCTION_SMTP_SSL=false \ @@ -388,8 +556,67 @@ PRODUCTION_ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=A5:74:2B:AE:70:C6:D0:34:E3 PRODUCTION_SUPPORT_INBOX_ADDRESS=support@help.test \ ./scripts/init-production-env.sh help.test "$production_env" >/dev/null test "$(stat -c '%a' "$production_env")" = 600 +grep -Fx "SMTP_PASSWORD=quality\$\$password" "$production_env" >/dev/null ./scripts/validate-production-env.sh "$production_env" help.test >/dev/null ./scripts/check-environment-readiness.sh "$production_env" --require-release >/dev/null +if PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ + PRODUCTION_WNH_FIREBASE_APPLICATION_ID=1:987654321:android:quality \ + PRODUCTION_WNH_FIREBASE_API_KEY=quality-public-client-key \ + PRODUCTION_WNH_FIREBASE_PROJECT_ID=quality-production \ + PRODUCTION_WNH_FIREBASE_GCM_SENDER_ID=123456789 \ + ./scripts/init-production-env.sh help.test \ + "$scan_dir/production.mismatched-firebase-init.env" >/dev/null 2>&1; then + echo "Production environment initializer accepted a Firebase application from another sender." >&2 + exit 1 +fi +if PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ + PRODUCTION_FCM_PROJECT_ID=quality-production \ + PRODUCTION_FCM_SERVICE_ACCOUNT_JSON_BASE64="$quality_other_fcm_base64" \ + ./scripts/init-production-env.sh help.test \ + "$scan_dir/production.mismatched-fcm-credential.env" >/dev/null 2>&1; then + echo "Production environment initializer accepted an FCM service account from another project." >&2 + exit 1 +fi +if PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ + PRODUCTION_WNH_FIREBASE_APPLICATION_ID=1:123456789:android:quality \ + PRODUCTION_WNH_FIREBASE_API_KEY=quality-public-client-key \ + PRODUCTION_WNH_FIREBASE_PROJECT_ID=quality-production \ + PRODUCTION_WNH_FIREBASE_GCM_SENDER_ID=123456789 \ + PRODUCTION_FCM_PROJECT_ID=quality-other \ + PRODUCTION_FCM_SERVICE_ACCOUNT_JSON_BASE64="$quality_other_fcm_base64" \ + ./scripts/init-production-env.sh help.test \ + "$scan_dir/production.mismatched-firebase-fcm.env" >/dev/null 2>&1; then + echo "Production environment initializer accepted different Firebase and FCM projects." >&2 + exit 1 +fi +if PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ + PRODUCTION_ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile.staging \ + PRODUCTION_ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=A5:74:2B:AE:70:C6:D0:34:E3:75:44:B6:2E:37:A3:75:C0:E0:05:64:74:50:F4:0F:29:B2:A9:84:F9:FD:B8:FB \ + ./scripts/init-production-env.sh help.test \ + "$scan_dir/production.staging-package.env" >/dev/null 2>&1; then + echo "Production environment initializer accepted the staging Android package." >&2 + exit 1 +fi +if PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ + PRODUCTION_WEB_PUSH_VAPID_PUBLIC_KEY=quality-vapid-public \ + PRODUCTION_WEB_PUSH_VAPID_PRIVATE_KEY=quality-vapid-private \ + PRODUCTION_WEB_PUSH_VAPID_SUBJECT=ftp://help.test \ + ./scripts/init-production-env.sh help.test \ + "$scan_dir/production.invalid-vapid-subject.env" >/dev/null 2>&1; then + echo "Production environment initializer accepted an invalid VAPID subject." >&2 + exit 1 +fi +injected_smtp_password=$( + printf 'quality-password\nGOOGLE_OAUTH_CLIENT_SECRET=injected' +) +if PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ + PRODUCTION_SMTP_PASSWORD="$injected_smtp_password" \ + ./scripts/init-production-env.sh help.test \ + "$scan_dir/production.injected-line.env" >/dev/null 2>&1; then + echo "Production environment initializer accepted a line-breaking credential." >&2 + exit 1 +fi +test ! -e "$scan_dir/production.injected-line.env" invalid_fcm_env="$scan_dir/production.invalid-fcm.env" invalid_fcm_base64=$( printf '%s' '{"type":"service_account","project_id":"quality-production"}' | @@ -984,6 +1211,105 @@ done echo "Building the pinned quality image and cached Dialyzer PLTs" docker build --target quality --tag "$quality_image" . +echo "Checking isolated VAPID generation and atomic single-file import" +generated_vapid_env="$scan_dir/generated-vapid.env" +cp .env.example "$generated_vapid_env" +chmod 600 "$generated_vapid_env" +vapid_output=$( + WNH_VAPID_GENERATOR_IMAGE="$quality_image" \ + ./scripts/generate-vapid-env.sh \ + "$generated_vapid_env" mailto:contact@help.test +) +generated_vapid_public=$( + awk -F= ' + $1 == "WEB_PUSH_VAPID_PUBLIC_KEY" { + print substr($0, index($0, "=") + 1) + exit + } + ' "$generated_vapid_env" +) +generated_vapid_private=$( + awk -F= ' + $1 == "WEB_PUSH_VAPID_PRIVATE_KEY" { + print substr($0, index($0, "=") + 1) + exit + } + ' "$generated_vapid_env" +) +test -n "$generated_vapid_public" +test -n "$generated_vapid_private" +test "$generated_vapid_public" != "$generated_vapid_private" +grep -Fx 'WEB_PUSH_VAPID_SUBJECT=mailto:contact@help.test' \ + "$generated_vapid_env" >/dev/null +if printf '%s' "$vapid_output" | + grep -F "$generated_vapid_public" >/dev/null || + printf '%s' "$vapid_output" | + grep -F "$generated_vapid_private" >/dev/null; then + echo "VAPID generator printed generated key material." >&2 + exit 1 +fi +docker run --rm \ + --network none \ + --read-only \ + --volume "$generated_vapid_env:/tmp/generated-vapid.env:ro" \ + --entrypoint elixir \ + "$quality_image" \ + -e ' + values = + "/tmp/generated-vapid.env" + |> File.read!() + |> String.split("\n", trim: true) + |> Enum.reject(&(String.starts_with?(&1, "#") or not String.contains?(&1, "="))) + |> Map.new(fn line -> + [key, value] = String.split(line, "=", parts: 2) + {key, value} + end) + + {:ok, public_key} = + Base.url_decode64(values["WEB_PUSH_VAPID_PUBLIC_KEY"], padding: false) + + {:ok, private_key} = + Base.url_decode64(values["WEB_PUSH_VAPID_PRIVATE_KEY"], padding: false) + + unless byte_size(public_key) == 65 and + :binary.first(public_key) == 4 and + byte_size(private_key) == 32 do + raise "unexpected VAPID key shape" + end + ' +if WNH_VAPID_GENERATOR_IMAGE="$quality_image" \ + ./scripts/generate-vapid-env.sh \ + "$generated_vapid_env" mailto:contact@help.test >/dev/null 2>&1; then + echo "VAPID generator rotated an existing environment identity." >&2 + exit 1 +fi +fresh_vapid_env="$scan_dir/fresh-vapid.env" +cp .env.example "$fresh_vapid_env" +chmod 600 "$fresh_vapid_env" +if WNH_VAPID_GENERATOR_IMAGE="$quality_image" \ + ./scripts/generate-vapid-env.sh \ + "$fresh_vapid_env" ftp://help.test >/dev/null 2>&1; then + echo "VAPID generator accepted an invalid subject." >&2 + exit 1 +fi +fresh_vapid_hash=$(sha256sum "$fresh_vapid_env" | awk '{print $1}') +injected_vapid_subject=$( + printf 'mailto:contact@help.test\nGOOGLE_OAUTH_CLIENT_SECRET=injected' +) +if WNH_VAPID_GENERATOR_IMAGE="$quality_image" \ + ./scripts/generate-vapid-env.sh \ + "$fresh_vapid_env" "$injected_vapid_subject" >/dev/null 2>&1; then + echo "VAPID generator accepted a line-breaking subject." >&2 + exit 1 +fi +test "$(sha256sum "$fresh_vapid_env" | awk '{print $1}')" = "$fresh_vapid_hash" +if find "$scan_dir" -maxdepth 1 -name '.vapid-generation.*' -print | + grep -q .; then + echo "VAPID generator retained a temporary credential directory." >&2 + exit 1 +fi +unset generated_vapid_public generated_vapid_private + echo "Running Elixir format, compiler, xref, Credo, Sobelow, Dialyzer, and Hex audit" docker run --rm "$quality_image" sh -euc ' mix format --check-formatted diff --git a/scripts/set-env-values.sh b/scripts/set-env-values.sh index aafaad7..098f2da 100755 --- a/scripts/set-env-values.sh +++ b/scripts/set-env-values.sh @@ -55,7 +55,9 @@ if ! awk ' exit 40 } - replacement[key] = substr($0, index($0, "=") + 1) + value = substr($0, index($0, "=") + 1) + gsub(/\$/, "$$", value) + replacement[key] = value replacement_count++ next }