diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 676020e..e005dc0 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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 | diff --git a/docs/architecture/scripts/preflight.sh b/docs/architecture/scripts/preflight.sh new file mode 100755 index 0000000..c99787c --- /dev/null +++ b/docs/architecture/scripts/preflight.sh @@ -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] []" + say " 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:-}" >&2 + printf ' 1 reset request for an address that does not exist to %s\n' "${RESET_PATH:-}" >&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."