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."