#!/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.
# ---------------------------------------------------------------------------
# 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}"

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