feat(security): preflight.sh, the live-URL checks

Four things auditors of applications of this kind report seeing over and over,
each of them 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 understanding to be checked, which is why
they belong in a script rather than a page somebody re-reads before a release
and then does not.

*(precautionary)* -- none of it has bitten a project here. The checks are cheap
and the evidence is somebody else's.

**Passive by default.** A bare run sends two GETs and could not be mistaken for
anything. Rate limiting and enumeration are behind --auth, because one of them
deliberately generates a dozen failed authentications.

**It refuses any host but its configured origin.** There is no URL argument that
can point it elsewhere: the target is PREFLIGHT_ORIGIN, and a URL on the command
line must match it. status.sh makes this argument for having no --host flag;
here there is more at stake, since a mistake there reads the wrong machine and a
mistake here hammers somebody else's login form from your address. The login and
reset paths are configured too, never guessed -- a POST to an assumed /login on
the wrong app posts to whatever is actually there.

Verified: exit 2 unconfigured, for a foreign host, and for an unreachable one;
exit 0 for --dry-run; exit 1 with the finding named. Run passively against
privacyllc.dev it correctly reported a strong CSP, a framing policy and HSTS,
and found that plain http answers 200 with the full page rather than
redirecting -- which is the class of finding this exists for, on its first real
target.

closes #9

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
null 2026-08-17 23:14:06 -05:00
parent 6d7a1e5eca
commit 9667b46585
2 changed files with 235 additions and 0 deletions

View File

@ -47,6 +47,7 @@ can stop you, and where to start in a fresh clone.
| `scripts/backup.sh` | a dump that is verified before it is trusted |
| `scripts/restore-check.sh` | the other half of `backup.sh`: restores the newest dump into a scratch database it creates and drops, counts the tables, and **times it** — the number an incident actually needs. Never accepts a target, because naming one is the mistake `--clean` punishes. |
| `scripts/healthcheck.sh` | a liveness tick with the URL written down rather than re-derived each run |
| `scripts/preflight.sh` | the live-URL checks: headers, TLS, and *(with `--auth`)* login rate limiting and account enumeration. Refuses any host but its configured origin — two of its checks generate failed logins and look like an attack in somebody's log. |
| `scripts/status.sh` | what is deployed, and whether it matches this checkout |
| `scripts/controls.sh` | which operational controls this project actually has, each row saying **how** it is known: measured, declared, n/a, or unknown. An unknown is never rendered as absent — "I could not tell" and "it is not there" send people to different places. |
| `scripts/dev.sh` | bring the local stack up |

View File

@ -0,0 +1,234 @@
#!/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.
#
# ## 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.
# ---------------------------------------------------------------------------
ORIGIN="${PREFLIGHT_ORIGIN:-}"
# 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:]')
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
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
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
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."