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:
parent
6d7a1e5eca
commit
9667b46585
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
Loading…
Reference in New Issue