Queue-North-Website/scripts/check-env.sh

900 lines
41 KiB
Bash
Raw Permalink Normal View History

chore: adopt template scripts and git hooks, retire phase-versioning Ten scripts from ~/.openclaw/Projects/Template, taken one at a time and configured against this deployment rather than copied wholesale. Configured, not just copied: - check-env.sh SPEC written from what server/index.js actually reads — 24 variables, each with the consequence of getting it wrong - secrets.sh plus this project's own shapes: a bare 60+ hex run, which is how the Zoho WebToLead tokens leaked into four commits, and a reCAPTCHA key shape as NOTED rather than a failure, because the site key and the secret key are indistinguishable by shape - status.sh nebula / qn-website-dev - healthcheck.sh /api/health, asserting 200 AND "status":"ok" AND "db":"ok". The template probed /healthz, which does not exist here - preflight.sh https://qn.isnull.dev, no --auth — there are no accounts - verify.sh GUARD_DIR=scripts/verify.d, since this project has no test runner and no typecheck for it to detect - backup.sh ENGINE block replaced for SQLite: better-sqlite3's online .backup() inside the container, verified with PRAGMA integrity_check before anything is renamed into place - restore-check.sh rewritten rather than configured — the template's is pg_restore/psql end to end with no seam. Replays the dump from SQL into a scratch database and times it Three guards in scripts/verify.d, because verify.sh would otherwise detect nothing and exit 2: the build, the tracked-tree secret scan, and a check that every document carries a valid Status, Governs and Review trigger. Every guard was proven to fail before being trusted, per GUARDS.md rule 1: healthcheck against a 200 that is not this app, secrets against the real historical leak replayed out of 033bdf6, doc-headers against both a missing Review trigger and the Status word "Historical", restore-check against a truncated dump, an empty database and a raised row floor. pre-commit is ADAPTED, not the template's. That one runs `npx tsc --noEmit` and `npx vitest run`; this project has neither, so unchanged it would refuse every commit. It runs the secret scan and `npm run build`. Hooks are not activated by this commit — `git config core.hooksPath .githooks` is a separate, per-clone act. package.json: adds `verify`, and corrects the version to 0.9.3. It said 0.8.3 while the last four commits said batch 0.9.0 through 0.9.3 — the second drift of the phase-versioning rule, which is retired in the following commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 01:18:20 -05:00
#!/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"
fix(infra): queuenorth.com is the production origin, and it is this deployment _null confirmed queuenorth.com as the permanent public origin, which settles #212 and exposed a wrong claim written earlier the same day. docs/OPERATIONS.md said queuenorth.com "is not this deployment ... do not diagnose against it". That came from a DNS lookup and an assumption. It is this deployment: both hostnames serve the identical bundle and this server's own /api/health shape, 24.41.108.95 is this network's own public IP, and both reach qn-website-dev on nebula — queuenorth.com through nginx-proxy-manager on thor/exodus, qn.isnull.dev through Cloudflare. Two front doors, one container, no non-production environment. That is the worst direction for a runbook to be wrong in, so the correction quotes the wrong sentence rather than replacing it silently. The QA Round 0 table likewise gained the production observations as extra rows instead of having its originals rewritten. Dockerfile: the CORS_ORIGIN fallback was '*'. The server sets credentials:true and browsers reject '*' with credentials outright, so that fallback would have broken every form rather than over-permitting. Now the real origin. healthcheck.sh and preflight.sh now watch production by default, with the second front door reachable through their env overrides — the two ingresses terminate TLS in different places and can rot independently. Also fills a gap adoption left explicitly undone: the deploy path is a Portainer stack, id 58 on nebula, found from the container's own compose labels. OPERATIONS.md documents it, including that the stack file is a separate copy from this repository's docker-compose.yml and the two have already drifted. That drift is all that remains of #212 — one trailing slash on line 21 of the stack file. Left in place: nothing is broken today, and fixing it recreates the container and takes both front doors down together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 02:08:12 -05:00
"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"
chore: adopt template scripts and git hooks, retire phase-versioning Ten scripts from ~/.openclaw/Projects/Template, taken one at a time and configured against this deployment rather than copied wholesale. Configured, not just copied: - check-env.sh SPEC written from what server/index.js actually reads — 24 variables, each with the consequence of getting it wrong - secrets.sh plus this project's own shapes: a bare 60+ hex run, which is how the Zoho WebToLead tokens leaked into four commits, and a reCAPTCHA key shape as NOTED rather than a failure, because the site key and the secret key are indistinguishable by shape - status.sh nebula / qn-website-dev - healthcheck.sh /api/health, asserting 200 AND "status":"ok" AND "db":"ok". The template probed /healthz, which does not exist here - preflight.sh https://qn.isnull.dev, no --auth — there are no accounts - verify.sh GUARD_DIR=scripts/verify.d, since this project has no test runner and no typecheck for it to detect - backup.sh ENGINE block replaced for SQLite: better-sqlite3's online .backup() inside the container, verified with PRAGMA integrity_check before anything is renamed into place - restore-check.sh rewritten rather than configured — the template's is pg_restore/psql end to end with no seam. Replays the dump from SQL into a scratch database and times it Three guards in scripts/verify.d, because verify.sh would otherwise detect nothing and exit 2: the build, the tracked-tree secret scan, and a check that every document carries a valid Status, Governs and Review trigger. Every guard was proven to fail before being trusted, per GUARDS.md rule 1: healthcheck against a 200 that is not this app, secrets against the real historical leak replayed out of 033bdf6, doc-headers against both a missing Review trigger and the Status word "Historical", restore-check against a truncated dump, an empty database and a raised row floor. pre-commit is ADAPTED, not the template's. That one runs `npx tsc --noEmit` and `npx vitest run`; this project has neither, so unchanged it would refuse every commit. It runs the secret scan and `npm run build`. Hooks are not activated by this commit — `git config core.hooksPath .githooks` is a separate, per-clone act. package.json: adds `verify`, and corrects the version to 0.9.3. It said 0.8.3 while the last four commits said batch 0.9.0 through 0.9.3 — the second drift of the phase-versioning rule, which is retired in the following commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 01:18:20 -05:00
"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:<reason>' or 'note:<reason>'
# — 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