Queue-North-Website/scripts/preflight.sh

283 lines
13 KiB
Bash
Raw 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
#
# The checks you can run against a live URL in two minutes, before strangers do.
#
# bash scripts/preflight.sh # passive: headers and TLS
# bash scripts/preflight.sh --auth # also the two that generate load
# bash scripts/preflight.sh --dry-run # print the plan, contact nothing
#
# Exit codes:
#
# 0 every check that ran found nothing
# 1 findings, each one named
# 2 NOTHING WAS CHECKED — unreachable, unconfigured, or curl missing. Not a
# pass: a site that could not be contacted and a site with no problems must
# never exit the same way.
#
# ===========================================================================
# TEMPLATE COPY — configure this before the first run
# ===========================================================================
#
# Set PREFLIGHT_ORIGIN to the one origin this copy is allowed to test.
#
# Assumes: bash, coreutils, `curl`.
#
# ## Why this exists
#
# These are the findings that people who audit applications of this kind report
# seeing over and over, and every one of them is mechanical: a header that is
# absent, a scheme that is plain, a login that answers a thousand guesses, a
# reset form that confirms which addresses have accounts. None needs to be
# understood to be checked, which is exactly why it belongs in a script rather
# than in a page somebody re-reads before each release and then does not.
#
# *(precautionary)*: none of this has bitten a project here yet. It is included
# because the checks are cheap and the evidence for them is somebody else's.
#
# ## It refuses to run against anything but its own origin
#
# Two of these checks — repeated bad logins, and asking whether an account
# exists — are indistinguishable from an attack in somebody's log, and one of
# them deliberately generates failed authentications. So there is no URL
# argument that can point this anywhere: the target comes from PREFLIGHT_ORIGIN
# and a URL passed on the command line must match it, or the run is refused.
#
# That is `status.sh`'s argument for having no --host flag, with more at stake:
# there, a mistake reads the wrong machine, and here it hammers somebody else's
# login form from your address.
#
# The active checks are further gated behind --auth, so the default run sends
# exactly two GETs and could not be mistaken for anything.
#
# ## Present is not in force
#
# It also counts each header rather than only looking for one. More than one
# copy means the later ones are discarded — RFC 6797 requires exactly that for
# HSTS and browsers do the same elsewhere — so a directive can sit in a response,
# be read by a person as applying, and have never once applied. Two layers each
# adding their own is all it takes.
#
# ## What it cannot tell you
#
# A header being present is not a header being correct — a CSP of
# `default-src *` is a CSP. This reports presence, which is the part that is
# mechanically checkable, and a present-but-useless policy is a job for a person
# or for securityheaders.com's grade. Absence is the common case and the one
# this catches.
set -uo pipefail
say() { printf '\033[1mpreflight:\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1mpreflight:\033[0m %s\n' "$*" >&2; exit 2; }
# ---------------------------------------------------------------------------
# CONFIGURATION — set this one, then delete this banner.
#
# The single origin this copy may contact, e.g. https://app.example.com.
# Empty on purpose: a default here is a script that attacks whatever origin the
# project it was copied from happened to use.
# ---------------------------------------------------------------------------
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
# The PRODUCTION origin. The second front door goes through completely
# different infrastructure, so its headers and TLS are a separate question:
#
# PREFLIGHT_ORIGIN=https://qn.isnull.dev bash scripts/preflight.sh
ORIGIN="${PREFLIGHT_ORIGIN:-https://queuenorth.com}"
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
# Paths used only by --auth. Unset means that check reports "not configured"
# rather than guessing at /login, which on the wrong app is a POST to something
# else entirely.
LOGIN_PATH="${PREFLIGHT_LOGIN_PATH:-}"
RESET_PATH="${PREFLIGHT_RESET_PATH:-}"
ATTEMPTS="${PREFLIGHT_LOGIN_ATTEMPTS:-12}"
AUTH=""
DRY_RUN=""
TARGET=""
while [ $# -gt 0 ]; do
case "$1" in
--auth) AUTH="yes"; shift ;;
--dry-run) DRY_RUN="yes"; shift ;;
-h|--help)
say "usage: bash scripts/preflight.sh [--auth] [--dry-run] [<url>]"
say " <url> must match PREFLIGHT_ORIGIN. Set PREFLIGHT_LOGIN_PATH"
say " and PREFLIGHT_RESET_PATH for --auth."
exit 0 ;;
-*) die "unknown argument '$1'." ;;
*) TARGET="$1"; shift ;;
esac
done
command -v curl >/dev/null 2>&1 || die "curl is not on PATH, so nothing could be checked."
[ -n "$ORIGIN" ] || die "set PREFLIGHT_ORIGIN — the one origin this copy may contact. See the CONFIGURATION block."
host_of() { printf '%s' "$1" | sed -E 's#^[a-zA-Z]+://##; s#/.*$##; s#:.*$##'; }
ORIGIN_HOST="$(host_of "$ORIGIN")"
[ -n "$ORIGIN_HOST" ] || die "PREFLIGHT_ORIGIN '$ORIGIN' does not look like a URL."
if [ -n "$TARGET" ]; then
TARGET_HOST="$(host_of "$TARGET")"
if [ "$TARGET_HOST" != "$ORIGIN_HOST" ]; then
say "refusing: '$TARGET' is host '$TARGET_HOST', and this copy is configured"
say " for '$ORIGIN_HOST'."
say "Two of these checks generate failed logins and look like an attack in"
say "somebody's log. Pointing them at a host this copy was not configured"
say "for is the mistake the refusal exists to prevent — change"
say "PREFLIGHT_ORIGIN deliberately if you mean it."
exit 2
fi
else
TARGET="$ORIGIN"
fi
case "$ATTEMPTS" in ''|*[!0-9]*) die "PREFLIGHT_LOGIN_ATTEMPTS must be a whole number, got '$ATTEMPTS'." ;; esac
if [ -n "$DRY_RUN" ]; then
say "--dry-run: nothing was contacted. Target: $TARGET"
say "would run:"
printf ' curl -sSI %s # headers, TLS\n' "$TARGET" >&2
printf ' curl -sSI http://%s/ # is plaintext served or redirected\n' "$ORIGIN_HOST" >&2
if [ -n "$AUTH" ]; then
printf ' %s POSTs of bad credentials to %s\n' "$ATTEMPTS" "${LOGIN_PATH:-<PREFLIGHT_LOGIN_PATH unset>}" >&2
printf ' 1 reset request for an address that does not exist to %s\n' "${RESET_PATH:-<PREFLIGHT_RESET_PATH unset>}" >&2
else
printf ' (--auth not given: the two active checks are skipped)\n' >&2
fi
exit 0
fi
findings=0
finding() { printf ' FINDING %s\n' "$*" >&2; findings=$((findings + 1)); }
ok() { printf ' ok %s\n' "$*" >&2; }
skip() { printf ' skipped %s\n' "$*" >&2; }
# ---------------------------------------------------------------------------
# Passive: one request, several answers.
# ---------------------------------------------------------------------------
HEADERS=$(curl -sS -I -L --max-time 20 "$TARGET" 2>/dev/null) \
|| die "could not reach $TARGET. Nothing was checked — this is not a report that the site is fine."
[ -n "$HEADERS" ] || die "$TARGET returned no headers. Nothing was checked."
lower_headers=$(printf '%s' "$HEADERS" | tr '[:upper:]' '[:lower:]')
# `curl -L` concatenates the headers of every response in the chain, so a header
# counted across all of them would report a redirect's copy as a duplicate of
# the final page's. Only the last response block is counted.
final_block() { awk 'tolower($0) ~ /^http\// { buf = "" } { buf = buf $0 "\n" } END { printf "%s", buf }'; }
final_headers=$(printf '%s' "$lower_headers" | final_block)
final_raw=$(printf '%s' "$HEADERS" | tr -d '\r' | final_block)
# Present is not the same as in force. RFC 6797 section 8.1 is explicit for HSTS
# -- more than one and the agent MUST process only the first -- and browsers
# behave the same way for the others: the second copy is discarded in silence.
# Two layers each adding their own is the ordinary cause, and the result reads
# to a person as though both applied.
#
# Found on this script's first real target: an origin serving
# strict-transport-security: max-age=63072000; includeSubDomains
# strict-transport-security: max-age=63072000; preload
# where preload had never once been in force.
check_duplicate() { # <header-name>
local name="$1" count first
count=$(printf '%s' "$final_headers" | grep -cE "^${name}:" || true)
[ "${count:-0}" -gt 1 ] || return 0
# Counted against the lowercased copy, but quoted from the original: echoing
# `includesubdomains` back at somebody who wrote `includeSubDomains` reports a
# value they did not send. Directive names are case-insensitive; the report
# should still show what is actually on the wire.
# The FIRST occurrence, within the FINAL response block. Both halves matter:
# RFC 6797 processes the first and discards the rest, and `curl -L` hands us
# the headers of every hop, so searching the whole buffer would quote a
# redirect's copy. Quoted from the original rather than the lowercased copy —
# echoing `includesubdomains` at somebody who wrote `includeSubDomains`
# reports a value they never sent.
first=$(printf '%s' "$final_raw" | grep -iE "^${name}:" | head -n 1 | sed -E "s/^[^:]*:[[:space:]]*//")
finding "${count} ${name} headers — only the first is processed, so what is in force is '${first}' and every later copy is discarded silently"
return 1
}
case "$TARGET" in
https://*) ok "the target is https" ;;
*) finding "the target is not https — everything in transit is readable, including the session cookie" ;;
esac
if printf '%s' "$lower_headers" | grep -q '^content-security-policy:'; then
check_duplicate content-security-policy && ok "content-security-policy present"
else
finding "no content-security-policy header — injected script has nothing to stop it"
fi
# Either header answers the framing question; frame-ancestors is the modern one
# and x-frame-options the one older browsers read, so one of the two is enough.
if printf '%s' "$lower_headers" | grep -q '^x-frame-options:' \
|| printf '%s' "$lower_headers" | grep -q 'frame-ancestors'; then
check_duplicate x-frame-options && ok "framing policy present"
else
finding "neither x-frame-options nor a csp frame-ancestors — the page can be framed and clickjacked"
fi
if printf '%s' "$lower_headers" | grep -q '^strict-transport-security:'; then
check_duplicate strict-transport-security && ok "strict-transport-security present"
else
finding "no strict-transport-security — the first request of each visit can still be plaintext"
fi
# Plain HTTP: a redirect is the right answer; a 200 is a site served in the clear.
PLAIN=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 "http://$ORIGIN_HOST/" 2>/dev/null || printf 'none')
case "$PLAIN" in
30[0-9]) ok "plain http redirects ($PLAIN)" ;;
none) skip "plain http did not answer at all, which is also fine" ;;
200) finding "plain http answered 200 — the site is served unencrypted as well as encrypted" ;;
*) skip "plain http answered $PLAIN, which is neither a redirect nor a page" ;;
esac
# ---------------------------------------------------------------------------
# Active, and only with --auth. These generate failed authentications.
# ---------------------------------------------------------------------------
if [ -z "$AUTH" ]; then
skip "rate limiting and account enumeration (pass --auth, and read what it does first)"
else
if [ -z "$LOGIN_PATH" ]; then
skip "rate limiting — PREFLIGHT_LOGIN_PATH unset, and guessing at /login POSTs to whatever is there"
else
say "sending $ATTEMPTS failed logins to ${TARGET%/}$LOGIN_PATH — this will appear in the logs"
limited=""
for i in $(seq 1 "$ATTEMPTS"); do
code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 \
-X POST -H 'Content-Type: application/json' \
--data '{"email":"preflight-probe@example.invalid","password":"not-a-real-password"}' \
"${TARGET%/}$LOGIN_PATH" 2>/dev/null || printf 'none')
case "$code" in 429|423) limited="$code at attempt $i"; break ;; esac
done
if [ -n "$limited" ]; then
ok "authentication is rate limited ($limited)"
else
finding "$ATTEMPTS failed logins in a row, no 429 and no lockout — a password list can be run against this overnight"
fi
fi
if [ -z "$RESET_PATH" ]; then
skip "account enumeration — PREFLIGHT_RESET_PATH unset"
else
BODY=$(curl -sS --max-time 10 -X POST -H 'Content-Type: application/json' \
--data '{"email":"definitely-not-registered-preflight@example.invalid"}' \
"${TARGET%/}$RESET_PATH" 2>/dev/null || printf '')
if printf '%s' "$BODY" | grep -qiE "no (account|user)|not (found|registered)|does not exist|unknown email"; then
finding "the reset endpoint says an unregistered address is unknown — that confirms which addresses DO have accounts, which is the input to a phishing or credential-stuffing list"
else
ok "the reset endpoint does not reveal whether the address is registered"
fi
fi
fi
say ""
if [ "$findings" -gt 0 ]; then
say "$findings finding(s) above."
exit 1
fi
say "no findings from the checks that ran."