Files
phishing/pezkuwi/host/test-publish.sh
T
SatoshiQaziMuhammed 093f6a15dc fix(host,sync): publish lists readable by nginx, and push the sync over a deploy key (#5)
The first publish on phishing.pezkuwichain.io copied the stage directory's
0700 mode onto the web root with rsync -a, and every list answered 403 until
the modes were put back by hand, about three minutes later. The publish script
now sets modes (--chmod=D755,F644), and test-publish.sh checks the web root's
directories are 755 and the lists 644; without --chmod it fails.

master is now protected by a ruleset (pull request, "lists" required). GitHub
refuses the Actions app as a bypass actor, so the sync's github.token push
would have been rejected. The sync now pushes over a write deploy key, the
repository's only one, kept as a secret of a "sync" environment that runs for
master only; the ruleset exempts deploy keys. GitHub's host keys are pinned
in pezkuwi/github_host_keys, and the job's token drops to contents: read.
2026-09-13 17:39:09 +04:00

128 lines
6.6 KiB
Bash

#!/usr/bin/env bash
# pezkuwi/host/test-publish.sh [lists directory]
#
# phishing-publish.sh against a scratch repository and web root: it publishes good lists, leaves
# index.html alone, and refuses, serving exactly what it served before, when master's history
# was rewritten, a protected domain would be blocked, the deny list shrinks too far, an entry
# ends in a dot, or all/ holds something other than top-level domain directories.
#
# The lists directory (default: the repository root) must hold publishable lists: all.json,
# address.json, all/. Nothing here touches a network or a real web root.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LISTS="$(cd "${1:-$HERE/../..}" && pwd)"
SB="$(mktemp -d "${TMPDIR:-/tmp}/phishing-publish-test.XXXXXX")"
trap 'rm -rf "$SB"' EXIT
PASS=0
FAIL=0
check() {
if [ "$2" = "$3" ]; then printf ' PASS %-58s %s\n' "$1" "$3"; PASS=$((PASS + 1))
else printf ' FAIL %-58s expected [%s] got [%s]\n' "$1" "$2" "$3"; FAIL=$((FAIL + 1)); fi
}
g() { git -C "$SB/work" -c user.email=t@example.invalid -c user.name=t "$@"; }
served() { (cd "$SB/www" && find . -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum) | sha256sum | cut -c1-16; }
publish() { "$SB/publish.sh" >"$SB/out" 2>&1; echo $?; }
# edit <message> <python applied to the lists in the work tree>
edit() {
(cd "$SB/work" && python3 -c "$2")
g add -A
g commit -qm "$1"
g push -q origin HEAD:master
}
sed -e "s#^REPO_URL=.*#REPO_URL=$SB/remote.git#" \
-e "s#^STATE_DIR=.*#STATE_DIR=$SB/state#" \
-e "s#^WEB_ROOT=.*#WEB_ROOT=$SB/www#" \
-e "s#^LOCK_FILE=.*#LOCK_FILE=$SB/publish.lock#" \
-e "s#--chown=root:root ##" \
"$HERE/phishing-publish.sh" >"$SB/publish.sh"
chmod +x "$SB/publish.sh"
if grep -nE '^(REPO_URL=https|STATE_DIR=/var|WEB_ROOT=/var|LOCK_FILE=/run)' "$SB/publish.sh"; then
echo "HARNESS: a production path survived the rewrite; refusing to run" >&2
exit 2
fi
git init -q --bare -b master "$SB/remote.git"
git init -q -b master "$SB/work"
g remote add origin "$SB/remote.git"
cp -r "$LISTS/all.json" "$LISTS/address.json" "$LISTS/all" "$SB/work/"
g add -A && g commit -qm lists && g push -q origin HEAD:master
# Every later case pushes to this remote. If the first push did not land, each would fail on git
# and the "served lists did not change" checks would pass for that reason alone.
if [ "$(git --git-dir="$SB/remote.git" rev-parse --verify -q refs/heads/master)" != "$(g rev-parse HEAD)" ]; then
echo "HARNESS: the scratch remote did not receive master; no case ran" >&2
exit 2
fi
# The web root as the host had it: older lists, a stray file the sync no longer writes, the page.
mkdir -p "$SB/www/all/com"
python3 - "$LISTS/all.json" "$SB/www" <<'PY'
import json, sys
deny = json.load(open(sys.argv[1]))["deny"]
older = deny[: len(deny) - len(deny) // 100]
json.dump({"allow": [], "deny": older, "denySub": []}, open(f"{sys.argv[2]}/all.json", "w"))
PY
printf '{}\n' >"$SB/www/address.json"
printf '[]\n' >"$SB/www/all/all.json"
printf '<html>page</html>\n' >"$SB/www/index.html"
INDEX="$(sha256sum "$SB/www/index.html")"
echo "== publishes good lists"
check "exit" 0 "$(publish)"
check "all.json is master's" "$(sha256sum <"$LISTS/all.json")" "$(sha256sum <"$SB/www/all.json")"
check "a shard is master's" "$(sha256sum <"$LISTS/all/com/all.json")" "$(sha256sum <"$SB/www/all/com/all.json")"
check "the stray all/all.json is gone" no "$([ -e "$SB/www/all/all.json" ] && echo yes || echo no)"
check "index.html untouched" "$INDEX" "$(sha256sum "$SB/www/index.html")"
# nginx reads as another user: a web root or list it cannot read answers 403.
check "the web root and its directories are 755" 0 "$(find "$SB/www" -type d ! -perm 755 | wc -l)"
check "the lists are 644" 0 "$(find "$SB/www" -type f ! -name index.html ! -perm 644 | wc -l)"
check "the published commit is recorded" "$(git -C "$SB/work" rev-parse HEAD)" "$(cat "$SB/state/published")"
check "nothing new: exit" 0 "$(publish)"
check "nothing new: silent" "" "$(cat "$SB/out")"
refused() { # refused <label> <python> <text the refusal must give>
local before rc
before="$(served)"
edit "$1" "$2"
rc="$(publish)"
check "$1: refused" "1 REFUSED" "$rc $(grep -o '^REFUSED' "$SB/out")"
# For the reason too: checks overlap, and a refusal from the wrong one would hide that the right
# one no longer works.
check "$1: for its reason" yes "$(grep -qF -- "$3" "$SB/out" && echo yes || echo no)"
check "$1: the served lists did not change" "$before" "$(served)"
g reset -q --hard HEAD~1
g push -q -f origin HEAD:master
rm -rf "$SB/state/repo.git"
}
L='import json, os; a = json.load(open("all.json"))'
W='; json.dump(a, open("all.json", "w"))'
refused "blocks a protected domain" "$L; a['deny'].append('pezkuwichain.io'); os.makedirs('all/io', exist_ok=True); s = json.load(open('all/io/all.json')) if os.path.exists('all/io/all.json') else []; json.dump(s + ['pezkuwichain.io'], open('all/io/all.json', 'w'))$W" "would block our domain pezkuwichain.io"
refused "shrinks more than the limit" "$L; keep = set(a['deny'][: len(a['deny']) // 2]); a['deny'] = [u for u in a['deny'] if u in keep]$W
for t in os.listdir('all'):
json.dump([u for u in json.load(open(f'all/{t}/all.json')) if u in keep], open(f'all/{t}/all.json', 'w'))" "the deny list would shrink"
refused "an entry ends in a dot" "$L; a['deny'].append('scam-test.xyz.')$W" "all/ and all.json disagree"
refused "a file in all/" "open('all/all.json', 'w').write('[]')" "is not a top-level domain directory"
refused "shards disagree with all.json" "import json; s = json.load(open('all/com/all.json')); json.dump(s[1:], open('all/com/all.json', 'w'))" "all/ and all.json disagree"
echo "== rewritten history"
edit "one more" "import json; a = json.load(open('address.json')); a['next.example'] = []; json.dump(a, open('address.json', 'w'))"
g commit -q --allow-empty -m "published next"
g push -q origin HEAD:master
[ "$(g rev-list --count HEAD)" = 3 ] || { echo "HARNESS: expected three commits before rewriting history" >&2; exit 2; }
check "a new commit publishes" 0 "$(publish)"
published="$(served)"
g reset -q --hard HEAD~2
# A different list on the rewritten branch, so publishing it would change what is served.
(cd "$SB/work" && python3 -c 'import json; a = json.load(open("address.json")); a["rewritten.example"] = []; json.dump(a, open("address.json", "w"))')
g commit -qam "rewritten"
g push -q -f origin HEAD:master
check "a non-descendant master: refused" "1 REFUSED" "$(publish) $(grep -o '^REFUSED' "$SB/out")"
check "a non-descendant master: served lists unchanged" "$published" "$(served)"
echo
echo "RESULT: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]