#!/usr/bin/env bash # # Assert that the environment this project needs is present AND plausible, # before the thing that needs it starts. Read-only: it measures and it reports. # # bash scripts/check-env.sh # check the process environment # bash scripts/check-env.sh --file .env # check a file instead — only the file # bash scripts/check-env.sh --quiet # failures only, for calling from a script # bash scripts/check-env.sh --list # print what is declared, check nothing # # Exit codes, because the caller is usually another script deciding whether to # continue: # # 0 every required variable is present, and everything that IS set has the # right shape # 1 problems found — they are listed, grouped, with the source named # 2 NOTHING WAS CHECKED — the spec is empty, or the source could not be # read. This is not a pass and CI must not treat it as one. # # All output goes to stderr, so `check-env.sh --quiet` can be called from the # middle of another script without polluting that script's stdout. # # =========================================================================== # TEMPLATE COPY — configure this before the first run # =========================================================================== # # Copy to `scripts/check-env.sh`, `chmod +x` it, and fill in the SPEC block # below — one line per variable. Nothing else needs editing. # # SPEC ships empty and the script exits 2 until it has an entry. An empty spec # would examine nothing and have nothing to report, and the natural way to # print that is "ok" — a green result from a checker that measured nothing. # That is the worst possible output, because the whole value of this script is # that everything downstream stops re-checking and starts trusting it. # # There are no default variable names, deliberately, for the reason release.sh # gives at length: a list inherited from another project is a list of the wrong # names. It passes — every variable it knows about really is set, in your shell, # by that other project's tooling — while every variable this project actually # reads goes unexamined. A checker that is confidently green about the wrong # environment is worse than no checker. # # Assumes: bash, coreutils. It reads nothing from the repository, and the only # thing it shells out to is `tr` for case folding — no docker, no git, no # network client — so it runs unchanged inside a scratch container. # # ## Why this exists # # Absence is the cheap failure. A variable that is not set fails at the first # line that reads it, loudly, usually before the process finishes starting. # # The expensive failure is a variable that is present and the wrong shape, # because nothing refuses to start: # # PORT=3000/tcp a compose port mapping pasted into a port field # DATABASE_URL=postgres://localhost/app # right on a laptop; inside a container `localhost` # is the container, and the database is elsewhere # NODE_ENV=Production matches no `=== "production"` anywhere, so every # branch takes its development arm in production # JWT_SECRET=devsecret the eight characters someone typed to get the dev # server up, now signing real sessions # API_URL=https://api.example.com/ # one trailing slash; every joined path is `//v1/...` # # All five start. Most of them serve traffic. That is nearly the whole of # "works on my machine, broken in the container": not a missing variable — a # variable with the wrong shape, in an environment nobody ever compared against # the one it was written for. So this checks SHAPE, not just presence. # # ## Every problem, in one pass # # The alternative is the loop everyone knows: start, crash on one variable, fix # it, start, crash on the next. Each turn of that loop costs a full boot, and # on a deploy target it costs a rollback. So nothing here short-circuits — the # whole spec is evaluated and the report names everything that is wrong at # once, grouped, with optional-and-absent shown separately because it is not a # problem and must never read as one. # # ## Secrets are measured, never printed # # Anything whose name looks like a credential, and anything declared with the # `secret-min-length` kind, is reported as "set, N characters" and never as its # value — including inside failure messages, which is where a careless # validator leaks: the whole point of a failure message is to show you what it # saw. CI keeps its logs for months and shows them to everyone with read access # to the repository, so a validator that echoes what it validated has published # the credential to a wider audience than the breach it was guarding against. # # The name pattern errs toward over-matching. A false positive costs one # unprinted value; a false negative costs a credential in a log. # # ## Where to call it # # From the container entrypoint before `exec`, from `predev`/`prestart`, and # from CI before the deploy step. A checker nobody calls is decoration, and the # environment is exactly the thing that differs between the place you tested # and the place it broke. # # ## What it deliberately does not do # # It does not connect to anything. It never opens DATABASE_URL or curls # API_URL. Reachability depends on a VPN, a network namespace and a machine # that may not be this one, so a reachability check fails on laptops for # reasons that are nobody's fault — and a check that cries wolf gets an # `|| true` appended within a week, taking the shape checks down with it. This # says the values are plausible. It does not say the services are up. # # It does not write, export, repair or default anything, so there is no # --dry-run: there is no outward-facing act to preview. A checker that fixes # what it finds is one you stop reading, and then it starts inventing the # values that reach production. # # It does not merge sources. With --file it reads the file and the process # environment is not consulted at all, which is the point: a variable that is # set in your shell and missing from the .env is precisely the bug you came # here to find, and merging hides it. # # It does not know your framework and never guesses a name. Every variable it # checks is one you declared. set -uo pipefail # --------------------------------------------------------------------------- # Snapshot the environment before this script assigns a single variable of its # own. Nothing above this line may set a variable. # # `${!name}` reads a SHELL variable, and this script's own — QUIET, VALUE, # SOURCE_LABEL, NAME_WIDTH, LIST_ONLY — live in the same namespace as the # environment it is measuring. Read live rather than from a snapshot, the # checker measures itself, in both directions and both are wrong: # # NAME_WIDTH declared in SPEC, unset in a scrubbed environment, reported # "ok — present and the right shape: 14" and exited 0. Green for # a variable nobody set, which is the one failure this file's # header spends forty lines arguing is worse than no checker. # QUIET=yes exported by the caller, reported "set, but empty" and exited 1, # because the assignment below overwrote the value while keeping # the export attribute. A failure nobody caused, on a variable # that was correct. # # Taken here the snapshot is the environment as this process received it, and # nothing this script does afterwards can alter what it reports. The _CE_ prefix # is reserved from SPEC below so the snapshot cannot collide in its turn. # --------------------------------------------------------------------------- # `compgen` is a bash builtin, but it belongs to programmable completion and a # bash built with --disable-progcomp does not have it. Without this the snapshot # would come back empty and every declared variable would be reported missing: # a confident, total, wrong answer. Refuse instead — that is a reason this # script could not measure the environment, which is what exit 2 means. die() # is not defined this early, so the message is written out longhand. if ! type -t compgen >/dev/null 2>&1; then printf '\033[1mcheck-env:\033[0m this bash has no `compgen`, so the environment cannot be\n' >&2 printf '\033[1mcheck-env:\033[0m read reliably and nothing was checked. That is not a pass.\n' >&2 exit 2 fi _CE_ENV_NAMES=() _CE_ENV_VALS=() while IFS= read -r _CE_N; do [ -n "$_CE_N" ] || continue # `export FOO` with no value is exported-but-unset: it is not in a child's # environment either, so it is absence rather than an empty value. `+x` # keeps set-to-empty, which `docker run -e FOO` produces and which is a # different bug from unset. [ -n "${!_CE_N+x}" ] || continue _CE_ENV_NAMES+=("$_CE_N") _CE_ENV_VALS+=("${!_CE_N}") done <<<"$(compgen -e)" unset _CE_N # --------------------------------------------------------------------------- # SPEC — the variables this project needs. One line each, then delete this # banner. Adding a variable later is a one-line edit and nothing else. # # "NAME|required|kind|what breaks when it is missing or wrong" # # The description is not documentation. It is the sentence printed next to the # failure at 03:00, so write the CONSEQUENCE ("sessions cannot be signed, every # login 500s") rather than a restatement of the name ("the JWT secret"). # # required | optional — spelled exactly. A typo is refused rather than read as # "optional", because silently downgrading a required # variable is the one mistake this script cannot survive. # # Kinds: # nonempty any non-empty string; for values whose shape is # genuinely unknowable. Not a lazy default — it is the # honest one, and it still catches unset and empty. # int a whole number # int:MIN..MAX a whole number within bounds, inclusive # port 1-65535 # url scheme://host[...]; rejects a bare `host:5432` # path a filesystem path; existence is reported only when the # source is this machine's own environment # one-of:a,b,c exactly one of these, compared case-sensitively # secret-min-length:N at least N characters; the value is never printed, and # it is checked against well-known placeholders # # Examples of the FORM. Delete them — they are shapes, not your variables: # # "DATABASE_URL|required|url|nothing can read or write; every request 500s" # "PORT|optional|port|the server binds its own default, which the proxy is not pointed at" # "NODE_ENV|required|one-of:development,production,test|the wrong branch of every environment check" # "SESSION_SECRET|required|secret-min-length:32|sessions are forgeable" # "UPLOAD_DIR|optional|path|uploads land somewhere nothing serves and nothing backs up" # --------------------------------------------------------------------------- SPEC=( # --- Always. Wrong here and the site is wrong for everybody. --- "NODE_ENV|required|one-of:development,production,test|CSP relaxes in dev and the HTTP-to-HTTPS redirect only fires in production; the wrong value ships dev CSP to the public origin, or redirects a local dev server into a loop" "SERVER_PORT|required|port|Express binds 3001 by default and the reverse proxy in front of nebula is pointed at whatever this says; a mismatch is a site that is running and unreachable" "CORS_ORIGIN|required|url|the production origin, https://queuenorth.com, with NO trailing slash — a slash matches no real browser origin and silently blocks every cross-origin form post while the server logs nothing wrong. Both public hostnames reach the same container, so the site's own forms are same-origin and unaffected; this is about anything that is not" "LOG_LEVEL|optional|one-of:error,warn,info,debug|defaults to info. At error you lose the request log, which is the only record of traffic this project keeps" "RATE_LIMIT_PER_MINUTE|optional|int:1..1000|defaults to 5 per IP across all of /api. Unset is fine; set to something huge and the two POST endpoints are open to a bot overnight" # --- Anti-abuse. The only thing between two open POST endpoints and a bot. --- "RECAPTCHA_ENABLED|required|one-of:true,false|anything other than the literal string true disables verification silently. Declared required so that switching it off is a decision somebody typed, not a variable somebody forgot" "RECAPTCHA_SECRET_KEY|optional|secret-min-length:32|required in practice whenever RECAPTCHA_ENABLED=true — without it every verification fails open and the forms have no protection at all. Optional here only because this script cannot express the conditional" "RECAPTCHA_MIN_SCORE|optional|nonempty|defaults to 0.5. A float, so it is not range-checked here; set it to 0 and every bot passes" "VITE_RECAPTCHA_SITE_KEY|optional|nonempty|BUILD TIME, not runtime — Vite inlines it into dist/. Absent at build and the widget never loads, and no restart fixes it. It must be the SITE key; the secret key here ships the secret to every visitor" # --- CRM forwarding. Which half matters depends on the mode. --- "ZOHO_FORWARDING_MODE|required|one-of:webtolead,api|selects which of the two blocks below is actually read. Production is webtolead; the api path is a configured standby. Wrong value and leads are written to SQLite and forwarded nowhere, silently, because the forward is fire-and-forget by design" "ZOHO_WEBTOLEAD_ENABLED|optional|one-of:true,false|the on switch for the mode production actually uses. False means no lead reaches the CRM and the visitor still sees success" "ZOHO_WEBTOLEAD_URL|optional|url|defaults to the Zoho form endpoint. A wrong host means every forward posts into the void" "ZOHO_WEBTOLEAD_XNQSJSDP|optional|secret-min-length:32|the form identifier. Missing or wrong and Zoho rejects the post; leads survive in SQLite and never appear in the CRM" "ZOHO_WEBTOLEAD_XMIWTLD|optional|secret-min-length:32|the second form identifier, same consequence" "ZOHO_WEBTOLEAD_ACTION_TYPE|optional|nonempty|base64 for the target module. Defaults to Leads; wrong and records land in the wrong module" "ZOHO_WEBTOLEAD_RETURN_URL|optional|nonempty|the literal string null is correct here — server-to-server, nothing redirects. Not url-checked for that reason" "ZOHO_WEBTOLEAD_ZC_GAD|optional|nonempty|Zoho ad-tracking field, legitimately empty" "ZOHO_ENABLED|optional|one-of:true,false|the on switch for the OAuth/REST standby path. Irrelevant while the mode is webtolead" "ZOHO_CASES_ENABLED|optional|one-of:true,false|forwards support requests as Zoho Cases. Off means support tickets exist only in SQLite, where nobody is looking at them" "ZOHO_API_DOMAIN|optional|url|the API host for the standby path. Datacenter-specific" "ZOHO_ACCOUNTS_DOMAIN|optional|url|the OAUTH TOKEN host, which is a DIFFERENT domain from the API host. Pointing this at the API domain is the bug that shipped once and failed every token refresh in production" "ZOHO_CLIENT_ID|optional|nonempty|standby path only" "ZOHO_CLIENT_SECRET|optional|secret-min-length:16|standby path only" "ZOHO_REFRESH_TOKEN|optional|secret-min-length:16|standby path only. Grants write access to the CRM" ) # --------------------------------------------------------------------------- # Redaction. See "Secrets are measured, never printed" above. # # Matched case-insensitively against the whole variable name, so KEY catches # API_KEY, KEYCLOAK_SECRET and MONKEY_HOST alike. The last is a false positive # and costs nothing: its value is described rather than shown. # --------------------------------------------------------------------------- SECRET_NAME_PATTERN='SECRET|TOKEN|PASSWORD|PASSWD|PWD|CREDENTIAL|PRIVATE|SALT|SIGNATURE|SIGNING|AUTH|KEY|DSN|COOKIE' # Values a `secret-min-length` variable must not be, compared in lower case and # never echoed. These are the strings typed to make the dev server start, which # then travel to production inside a copied .env and satisfy every length rule. PLACEHOLDER_SECRETS='changeme change-me change_me changethis secret mysecret supersecret password passwd hunter2 test testing example placeholder todo tbd xxx xxxx admin dev devsecret development your-secret-here your_secret_here notasecret 123456 12345678 abc123' # Redaction applies to NAMES read out of a file too, not only to values. # # A multi-line secret pasted into a .env — a PEM body, a wrapped base64 blob — # has continuation lines carrying '=' padding, so `KEY=VALUE` parsing splits one # into a "name" and a "value" and the name is a slice of the credential. Every # message that prints a name out of a file therefore prints its LENGTH once it # is longer than any name could plausibly be, which is the same rule values # already follow. 40 covers the longest real variable names (the framework # ones reach the low forties); a 64-column PEM line does not come close. NAME_PRINT_MAX=40 say() { printf '\033[1mcheck-env:\033[0m %s\n' "$*" >&2; } # die is 2, not the 1 its counterpart in release.sh uses: everything that calls # it is a reason this script could not measure the environment, and a caller # must be able to tell "your configuration is wrong" from "I never found out". die() { printf '\033[1mcheck-env:\033[0m %s\n' "$*" >&2; exit 2; } usage() { say "usage: check-env.sh [--file PATH] [--quiet] [--list] [--help]" say " --file PATH check that file instead of the process environment" say " --quiet, -q print only failures (exit code still says everything)" say " --list print the declared spec and check nothing" say " --help, -h this message" } trim() { local s="$1" s="${s#"${s%%[![:space:]]*}"}" s="${s%"${s##*[![:space:]]}"}" printf '%s' "$s" } SOURCE_FILE="" QUIET="" LIST_ONLY="" while [ $# -gt 0 ]; do case "$1" in --file) shift; [ $# -gt 0 ] || die "--file needs a path."; SOURCE_FILE="$1" ;; --file=*) SOURCE_FILE="${1#--file=}"; [ -n "$SOURCE_FILE" ] || die "--file needs a path." ;; --quiet|-q) QUIET="yes" ;; --list) LIST_ONLY="yes" ;; -h|--help) usage; exit 0 ;; *) usage; die "unknown argument '$1'." ;; esac shift done # --------------------------------------------------------------------------- # Read the spec, and refuse anything ambiguous. # # A malformed spec entry is a configuration error in this script, not a finding # about the environment, so it exits 2 and reports nothing about the variables: # a partial report from a spec that is half-understood is the same lie as a # green one from an empty spec. # --------------------------------------------------------------------------- if [ "${#SPEC[@]}" -eq 0 ]; then say "SPEC is empty, so nothing was checked. That is not a pass." say "" say "This is a fresh template copy: declare the variables this project reads" say "in the SPEC block near the top of this file, one line each —" say ' "DATABASE_URL|required|url|nothing can read or write; every request 500s"' die "then run this again." fi SPEC_NAME=() SPEC_REQ=() SPEC_KIND=() SPEC_PARAM=() SPEC_DESC=() NAME_WIDTH=4 entry_no=0 for entry in "${SPEC[@]}"; do entry_no=$((entry_no + 1)) # A description may legitimately contain '|', so read takes only four fields # and the fourth keeps the rest. IFS='|' read -r f_name f_req f_kind f_desc <<<"$entry" f_name=$(trim "${f_name:-}") f_req=$(trim "${f_req:-}") f_kind=$(trim "${f_kind:-}") f_desc=$(trim "${f_desc:-}") [ -n "$f_name" ] && [ -n "$f_req" ] && [ -n "$f_kind" ] && [ -n "$f_desc" ] \ || die "SPEC entry ${entry_no} is not NAME|required|kind|description: '${entry}'" # The shell cannot export a name it cannot parse, so a name that fails this # could never have been set in the first place and every run would report it # missing forever. [[ "$f_name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] \ || die "SPEC entry ${entry_no}: '${f_name}' is not a usable variable name." # The environment snapshot has to live somewhere, and a variable it could # collide with is one this script would measure against itself. Refused # rather than measured wrong. case "$f_name" in _CE_*) die "SPEC entry ${entry_no}: '${f_name}' uses the _CE_ prefix, which this script reserves for the environment snapshot it takes before it defines anything. Rename the variable, or rename the snapshot at the top of this file." ;; esac case "$f_req" in required|optional) : ;; *) die "SPEC entry ${entry_no} (${f_name}): expected 'required' or 'optional', got '${f_req}'. Spelling matters here — see the SPEC block." ;; esac kind_name="${f_kind%%:*}" kind_param="" case "$f_kind" in *:*) kind_param="${f_kind#*:}" ;; esac case "$kind_name" in nonempty|url|port|path) [ -z "$kind_param" ] || die "SPEC entry ${entry_no} (${f_name}): kind '${kind_name}' takes no ':' argument." ;; int) if [ -n "$kind_param" ]; then [[ "$kind_param" =~ ^[+-]?[0-9]+\.\.[+-]?[0-9]+$ ]] \ || die "SPEC entry ${entry_no} (${f_name}): int bounds must look like 1..100, got '${kind_param}'." fi ;; one-of) [ -n "$kind_param" ] || die "SPEC entry ${entry_no} (${f_name}): one-of needs a comma-separated list, e.g. one-of:development,production." ;; secret-min-length) [[ "$kind_param" =~ ^[0-9]+$ ]] && [ "$kind_param" -gt 0 ] \ || die "SPEC entry ${entry_no} (${f_name}): secret-min-length needs a positive number, e.g. secret-min-length:32." ;; *) die "SPEC entry ${entry_no} (${f_name}): unknown kind '${kind_name}'. Valid: nonempty, int, port, url, path, one-of:a,b, secret-min-length:N." ;; esac # A name declared twice gets two descriptions, and the report would print # both — one of which is now out of date and neither of which is marked. for existing in ${SPEC_NAME[@]+"${SPEC_NAME[@]}"}; do [ "$existing" = "$f_name" ] && die "SPEC declares ${f_name} twice. Keep one line." done SPEC_NAME+=("$f_name") SPEC_REQ+=("$f_req") SPEC_KIND+=("$kind_name") SPEC_PARAM+=("$kind_param") SPEC_DESC+=("$f_desc") [ "${#f_name}" -gt "$NAME_WIDTH" ] && NAME_WIDTH="${#f_name}" done if [ -n "$LIST_ONLY" ]; then say "declared in SPEC — nothing was checked:" for (( i = 0; i < ${#SPEC_NAME[@]}; i++ )); do kind="${SPEC_KIND[$i]}" [ -n "${SPEC_PARAM[$i]}" ] && kind="${kind}:${SPEC_PARAM[$i]}" printf ' %-*s %-8s %-22s %s\n' \ "$NAME_WIDTH" "${SPEC_NAME[$i]}" "${SPEC_REQ[$i]}" "$kind" "${SPEC_DESC[$i]}" >&2 done exit 0 fi # --------------------------------------------------------------------------- # Display. Every message that mentions a value goes through show(), and that is # the only place the redaction rule is enforced — so nothing else in this # script may interpolate a value into a message. If you add a check, use it. # --------------------------------------------------------------------------- is_secret() { case "$2" in secret-min-length) return 0 ;; esac local upper upper=$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]') [[ "$upper" =~ $SECRET_NAME_PATTERN ]] } show() { local name="$1" kind="$2" value="$3" if is_secret "$name" "$kind"; then printf 'set, %d characters (value withheld)' "${#value}" return 0 fi case "$kind" in url) # Scheme and host are what you need to spot the wrong environment; the # path and query are where webhook and signed-URL secrets live, so they # are summarised rather than shown. local rest authority tail="" case "$value" in *://*) rest="${value#*://}" authority="${rest%%/*}" authority="${authority%%\?*}" case "$authority" in *@*) authority="***@${authority##*@}" ;; esac case "$rest" in */*|*\?*) tail="/…" ;; esac printf '%s://%s%s' "${value%%://*}" "$authority" "$tail" return 0 ;; esac ;; # Bounded, enumerable and not credential-shaped by construction. `literal` # is the mode a failure message asks for when the value IS the finding — # a port of '3000/tcp' cannot be explained without quoting it. int|port|one-of|path|literal) : ;; *) # Unknown shape, so nothing is known about what might be inside it. printf 'set, %d characters' "${#value}" return 0 ;; esac if [ "${#value}" -gt 60 ]; then printf '%s…' "${value:0:59}" else printf '%s' "$value" fi } # --------------------------------------------------------------------------- # Load the source. # --------------------------------------------------------------------------- FILE_KEYS=() FILE_VALS=() FILE_LINES=() FILE_BAD=() FILE_NOTES=() load_file() { local path="$1" lineno=0 line trimmed key val # Redirection, not `cat "$path" |`: a piped while-loop runs in a subshell and # every array appended inside it is discarded at the `done`. while IFS= read -r line || [ -n "$line" ]; do lineno=$((lineno + 1)) case "$line" in *$'\r') line="${line%$'\r'}" FILE_NOTES+=("line ${lineno}|has a CRLF line ending; most loaders keep the carriage return, making the value one invisible character longer than it looks") ;; esac trimmed=$(trim "$line") case "$trimmed" in ''|'#'*) continue ;; esac case "$trimmed" in export\ *|export$'\t'*) trimmed=$(trim "${trimmed#export}") FILE_NOTES+=("line ${lineno}|uses 'export'; a shell sourcing this file is fine, but docker --env-file reads the name as 'export NAME'") ;; esac case "$trimmed" in *=*) key="${trimmed%%=*}"; val="${trimmed#*=}" ;; *) FILE_BAD+=("line ${lineno}|no '=', so it sets nothing at all"); continue ;; esac if [[ ! "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then if [[ "$(trim "$key")" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then FILE_BAD+=("line ${lineno}|has spaces around '='; outside a shell those become part of the name and of the value") elif [ "${#key}" -gt "$NAME_PRINT_MAX" ]; then FILE_BAD+=("line ${lineno}|has ${#key} characters before its '=', so that is not a name and nothing can read it; the text is withheld in case this is one line of a wrapped multi-line secret") else FILE_BAD+=("line ${lineno}|'${key}' is not a usable variable name, so nothing can read it") fi continue fi # Quotes are stripped here because that is what dotenv-style loaders do — # but docker --env-file does not, so the two see different values. Said out # loud rather than resolved, because only the operator knows which loader # will read this file. case "$val" in \"*\"|\'*\') val="${val:1:${#val}-2}" FILE_NOTES+=("${key}|is quoted; dotenv-style loaders strip the quotes and docker --env-file keeps them, so the two disagree about this value") ;; esac case "$val" in *'${'*|*'$('*) FILE_NOTES+=("${key}|contains a \$ expansion; dotenv-style loaders interpolate it and docker --env-file passes it through literally") ;; esac local j for (( j = 0; j < ${#FILE_KEYS[@]}; j++ )); do if [ "${FILE_KEYS[$j]}" = "$key" ]; then FILE_NOTES+=("${key}|is set twice (lines ${FILE_LINES[$j]} and ${lineno}); loaders disagree about which one wins") break fi done FILE_KEYS+=("$key") FILE_VALS+=("$val") FILE_LINES+=("$lineno") done < "$path" } if [ -n "$SOURCE_FILE" ]; then # Refused rather than fallen back on. You asked about a file; answering about # the process environment instead would be a confident pass from a source you # did not name — see status.sh on why a tool that answers about the wrong # thing is worse than one that answers nothing. [ -e "$SOURCE_FILE" ] || die "--file '${SOURCE_FILE}' does not exist. Refusing to check the process environment instead." [ -f "$SOURCE_FILE" ] || die "--file '${SOURCE_FILE}' is not a regular file." [ -r "$SOURCE_FILE" ] || die "--file '${SOURCE_FILE}' is not readable by this user." load_file "$SOURCE_FILE" SOURCE_LABEL="${SOURCE_FILE}" else SOURCE_LABEL="the process environment" fi # The empty string means "not set here". Callers must not read it as "set to # nothing" — FOUND says which. FOUND="" VALUE="" # The locals are prefixed for the same reason the snapshot is: `lookup`'s own # `want` and `j` would shadow a SPEC entry of either name, and this is the one # function that resolves a variable name at runtime. lookup() { local _ce_want="$1" _ce_j FOUND="" VALUE="" if [ -n "$SOURCE_FILE" ]; then # No break: the last assignment wins, which is what a loader reading the # file top to bottom into a map does. for (( _ce_j = 0; _ce_j < ${#FILE_KEYS[@]}; _ce_j++ )); do if [ "${FILE_KEYS[$_ce_j]}" = "$_ce_want" ]; then FOUND="yes"; VALUE="${FILE_VALS[$_ce_j]}"; fi done else # The snapshot taken at the top, never the live variable: see the reasoning # there. A name absent from the snapshot was absent from the environment, # whatever this script may since have set in its own namespace. for (( _ce_j = 0; _ce_j < ${#_CE_ENV_NAMES[@]}; _ce_j++ )); do if [ "${_CE_ENV_NAMES[$_ce_j]}" = "$_ce_want" ]; then FOUND="yes"; VALUE="${_CE_ENV_VALS[$_ce_j]}"; break fi done fi } # --------------------------------------------------------------------------- # Shape checks. # # check_shape prints zero or more lines, each 'fail:' or 'note:' # — a protocol rather than array appends because it is called in a command # substitution, and an array appended inside a subshell is gone at the closing # paren. # --------------------------------------------------------------------------- check_shape() { local name="$1" kind="$2" param="$3" value="$4" case "$value" in ' '*|$'\t'*) printf 'fail:begins with whitespace, which is part of the value everywhere except a shell\n' ;; esac case "$value" in *' '|*$'\t') printf 'fail:ends with whitespace, which is part of the value and breaks every comparison against it\n' ;; esac case "$kind" in url|port|int|one-of|path) case "$value" in *$'\n'*) printf 'fail:contains a newline, so nothing downstream will parse it\n'; return 0 ;; esac ;; esac case "$kind" in nonempty) # Presence and non-emptiness are the same test, already done by the # caller. Reaching here means it passed; there is nothing else to know. : ;; int) if [[ ! "$value" =~ ^[+-]?[0-9]+$ ]]; then printf 'fail:is not a whole number: %s\n' "$(show "$name" "$kind" "$value")" return 0 fi local n="${value#+}" # Bash arithmetic is 64-bit and errors past it, so an absurd number is # rejected by length before anything tries to compare it. if [ "${#n}" -gt 18 ]; then printf 'fail:has %d digits; nothing downstream will read that as a number\n' "${#n}" return 0 fi if [ -n "$param" ]; then local lo="${param%%..*}" hi="${param##*..}" if [ "$n" -lt "${lo#+}" ] || [ "$n" -gt "${hi#+}" ]; then # Through show(), not the bare value: an out-of-range number under a # credential-shaped name — TOTP_KEY, AUTH_TOKEN_TTL — is still a # credential, and this is a failure message, which is precisely where # the header says a careless validator leaks. The bounds come from the # spec, so they are always safe to print. printf 'fail:is %s, outside the allowed %s..%s\n' \ "$(show "$name" "$kind" "$value")" "${lo#+}" "${hi#+}" fi fi ;; port) case "$value" in *://*) printf 'fail:is a URL, not a port number: %s\n' "$(show "$name" url "$value")"; return 0 ;; */*) printf 'fail:looks like a compose port mapping (%s); a port field takes the number alone\n' "$(show "$name" literal "$value")"; return 0 ;; esac if [[ ! "$value" =~ ^[0-9]{1,5}$ ]] || [ "$value" -lt 1 ] || [ "$value" -gt 65535 ]; then printf 'fail:is not a port; ports are 1-65535\n' return 0 fi ;; url) case "$value" in *://*) : ;; *) printf 'fail:has no scheme — %s is a host, not a URL; write it as scheme://host\n' "$(show "$name" literal "$value")" return 0 ;; esac local scheme="${value%%://*}" rest="${value#*://}" authority if [[ ! "$scheme" =~ ^[A-Za-z][A-Za-z0-9+.-]*$ ]]; then printf 'fail:has a scheme that is not a scheme\n' return 0 fi authority="${rest%%/*}" authority="${authority%%\?*}" case "$authority" in *@*) authority="${authority##*@}" ;; esac if [ -z "$authority" ]; then printf 'fail:has no host between // and the path\n' return 0 fi case "$authority" in *' '*) printf 'fail:has a space in the host\n'; return 0 ;; esac case "$authority" in localhost|localhost:*|127.0.0.1|127.0.0.1:*|0.0.0.0|0.0.0.0:*|'[::1]'|'[::1]':*) printf 'note:points at %s — correct on a laptop; inside a container that is the container itself, not the host\n' "$authority" ;; host.docker.internal*) printf 'note:points at host.docker.internal, which resolves on Docker Desktop and not on a Linux daemon without an extra_hosts entry\n' ;; esac case "$value" in */) printf 'note:ends with a slash, so anything joining a path onto it produces a double slash\n' ;; esac ;; path) case "$value" in '~'*) printf 'fail:starts with ~, and nothing expands a tilde inside an environment variable — a process reading this opens a directory literally named ~\n' ;; esac case "$value" in /*) : ;; *) printf 'note:is relative, so it resolves against the working directory of whatever starts the process rather than the one you typed it in\n' ;; esac # Existence is a fact about THIS machine. Checking it against a file that # describes another machine would report a fault that is not one, so in # --file mode it is not checked and the report says so rather than # leaving a silent gap. if [ -z "$SOURCE_FILE" ] && [ ! -e "$value" ]; then printf 'note:does not exist on this machine (this may be correct if the volume is mounted later)\n' fi ;; one-of) local rest="$param" opt matched="" ci_match="" local lower_value lower_opt lower_value=$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]') while [ -n "$rest" ]; do opt="${rest%%,*}" [ "$value" = "$opt" ] && matched="yes" lower_opt=$(printf '%s' "$opt" | tr '[:upper:]' '[:lower:]') [ "$lower_value" = "$lower_opt" ] && ci_match="$opt" case "$rest" in *,*) rest="${rest#*,}" ;; *) rest="" ;; esac done if [ -z "$matched" ]; then if [ -n "$ci_match" ]; then printf 'fail:is %s, and the comparison is case-sensitive: write %s\n' "$(show "$name" one-of "$value")" "$ci_match" else printf 'fail:is %s; allowed: %s\n' "$(show "$name" one-of "$value")" "${param//,/, }" fi fi ;; secret-min-length) # Length is the only property reported. Nothing below prints the value, # and the comparison below pipes it to `tr` rather than passing it as an # argument, so it never appears in this process's command line either. if [ "${#value}" -lt "$param" ]; then printf 'fail:is %d characters and the spec requires %d (value withheld)\n' "${#value}" "$param" fi local lower placeholder lower=$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]') for placeholder in $PLACEHOLDER_SECRETS; do if [ "$lower" = "$placeholder" ]; then printf 'fail:is a well-known placeholder, not a secret — anyone can guess it (value withheld)\n' break fi done ;; esac } # --------------------------------------------------------------------------- # Measure everything, then report. Nothing exits early: one run must name every # problem, or fixing them costs one restart each. # --------------------------------------------------------------------------- MISSING=() MALFORMED=() NOTES=() OPT_ABSENT=() PRESENT_OK=() for (( i = 0; i < ${#SPEC_NAME[@]}; i++ )); do name="${SPEC_NAME[$i]}" req="${SPEC_REQ[$i]}" kind="${SPEC_KIND[$i]}" param="${SPEC_PARAM[$i]}" desc="${SPEC_DESC[$i]}" lookup "$name" if [ -z "$FOUND" ]; then if [ "$req" = "required" ]; then MISSING+=("${name}|${desc}") else OPT_ABSENT+=("${name}|${desc}") fi continue fi if [ -z "$VALUE" ]; then if [ "$req" = "required" ]; then MISSING+=("${name}|set, but empty — ${desc}") else # Set-to-empty is not unset, and code tests it both ways: a truthiness # check sees nothing, a key-presence check sees it. Reported so the # ambiguity is the operator's to resolve rather than this script's. OPT_ABSENT+=("${name}|${desc}") NOTES+=("${name}|is set to the empty string, which is not the same as unset to code that checks whether the key is present") fi continue fi findings=$(check_shape "$name" "$kind" "$param" "$VALUE") had_fail="" while IFS= read -r rline; do [ -n "$rline" ] || continue case "$rline" in fail:*) MALFORMED+=("${name}|${rline#fail:}"); had_fail="yes" ;; note:*) NOTES+=("${name}|${rline#note:}") ;; esac done <<<"$findings" [ -n "$had_fail" ] || PRESENT_OK+=("${name}|$(show "$name" "$kind" "$VALUE")") done if [ -n "$SOURCE_FILE" ]; then # Only for a file. The process environment carries hundreds of variables from # the shell, the init system and every tool that ever exported one, so # "undeclared" there would be noise; in a file every line was written on # purpose, and one that matches nothing in the spec is usually a typo in a # name — the thing that makes people swear the value is right there. for (( j = 0; j < ${#FILE_KEYS[@]}; j++ )); do fkey="${FILE_KEYS[$j]}" known="" for name in ${SPEC_NAME[@]+"${SPEC_NAME[@]}"}; do [ "$name" = "$fkey" ] && known="yes" && break done if [ -z "$known" ]; then if [ "${#fkey}" -gt "$NAME_PRINT_MAX" ]; then FILE_NOTES+=("line ${FILE_LINES[$j]}|sets a ${#fkey}-character name that is not declared in SPEC and is too long to be a name at all; the text is withheld in case this is one line of a wrapped multi-line secret") else FILE_NOTES+=("${fkey}|is set on line ${FILE_LINES[$j]} but is not declared in SPEC — a misspelled name, or a spec that has fallen behind") fi fi done fi group() { local heading="$1"; shift [ $# -gt 0 ] || return 0 say "" say "$heading" local e for e in "$@"; do printf ' %-*s %s\n' "$NAME_WIDTH" "${e%%|*}" "${e#*|}" >&2 done } n_missing="${#MISSING[@]}" n_malformed="${#MALFORMED[@]}" n_badlines="${#FILE_BAD[@]}" n_problems=$(( n_missing + n_malformed + n_badlines )) # `${arr[@]+"${arr[@]}"}` throughout: under `set -u` a plain "${arr[@]}" on an # empty array is an error in bash before 4.4, and macOS still ships 3.2. if [ -z "$QUIET" ] || [ "$n_problems" -gt 0 ]; then say "source: ${SOURCE_LABEL}" if [ -n "$SOURCE_FILE" ]; then say " the process environment was NOT consulted, on purpose." fi fi if [ -z "$QUIET" ]; then group "ok — present and the right shape:" ${PRESENT_OK[@]+"${PRESENT_OK[@]}"} group "optional, not set — this is fine:" ${OPT_ABSENT[@]+"${OPT_ABSENT[@]}"} group "worth knowing — not failures:" ${NOTES[@]+"${NOTES[@]}"} # Kept apart from the value notes above: these are facts about how the FILE # is written, and they apply to whichever loader reads it rather than to any # one value being wrong. if [ -n "$SOURCE_FILE" ]; then group "about ${SOURCE_FILE} itself:" ${FILE_NOTES[@]+"${FILE_NOTES[@]}"} fi if [ -n "$SOURCE_FILE" ]; then for (( i = 0; i < ${#SPEC_KIND[@]}; i++ )); do if [ "${SPEC_KIND[$i]}" = "path" ]; then say "" say "note: path existence was not checked — a file can describe a machine" say " that is not this one. Run without --file to check paths here." break fi done fi fi group "MALFORMED — set, but the wrong shape:" ${MALFORMED[@]+"${MALFORMED[@]}"} group "UNREADABLE LINES — these set nothing:" ${FILE_BAD[@]+"${FILE_BAD[@]}"} group "MISSING — required, not set:" ${MISSING[@]+"${MISSING[@]}"} if [ "$n_problems" -gt 0 ]; then say "" say "${n_missing} missing, ${n_malformed} malformed, ${n_badlines} unreadable line(s) in ${SOURCE_LABEL}." # Malformed counts as a failure whether the variable was required or # optional: not setting an optional variable is a choice, and setting it to # something unusable is a mistake that reads as a working configuration. say "fix the entries above and run this again — nothing that reads this" say "environment can be trusted until then." # Exit 1, not die's 2. The difference is the whole contract: 1 means this # measured the environment and it is wrong, 2 means it never got far enough # to find out. A caller that cannot tell those apart will retry the wrong one. exit 1 fi if [ -z "$QUIET" ]; then say "" say "ok — ${#PRESENT_OK[@]} set and well-formed, ${#OPT_ABSENT[@]} optional and absent, ${#SPEC_NAME[@]} declared." say "shapes only: nothing was connected to, so this says the values are" say "plausible, not that the services behind them are up." fi exit 0