From 9232114af4a822b832e5e85dc1219b737fa01bb5 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 20 Oct 2026 09:04:44 -0700 Subject: [PATCH] tests: mail-tree fixture + host-side run-tests.sh cpmove-mail.tar.gz adds two Maildirs (one empty) and an ALFA-style symlink inside the mail tree. tests/run-tests.sh runs extract.sh and scan-files.php from the checkout with a stub clamscan: mail skipped by default, extracted with IMPORT_INCLUDE_MAIL=true, the in-tree symlink still stripped, --skip-mail excludes/never quarantines the mail tree. 12 of 23 checks fail on the previous scripts. Co-Authored-By: Claude Opus 5.5 --- tests/build-fixtures.sh | 39 +++++++++++++- tests/run-tests.sh | 117 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 2 deletions(-) create mode 100755 tests/run-tests.sh diff --git a/tests/build-fixtures.sh b/tests/build-fixtures.sh index 7914886..5e199ff 100755 --- a/tests/build-fixtures.sh +++ b/tests/build-fixtures.sh @@ -13,8 +13,17 @@ # `cpmove-testuser/homedir/.../alfasymlink -> /etc` # — the pre-extract scan MUST refuse this. # +# cpmove-mail.tar.gz — same shape PLUS a cPanel mail tree: +# homedir/etc//{passwd,shadow} and +# homedir/mail//{alice,empty}/ Maildirs +# (alice has 2 messages incl. one in .Archive; +# empty has cur/new/tmp and no messages), plus a +# DANGEROUS symlink INSIDE the mail tree +# (homedir/mail//alice/.evil -> /etc) that +# must be stripped even when mail is included. +# # Run: bash tests/build-fixtures.sh -# Output: tests/fixtures/cpmove-clean.tar.gz, tests/fixtures/cpmove-alfa.tar.gz +# Output: tests/fixtures/cpmove-{clean,alfa,mail}.tar.gz set -euo pipefail @@ -82,7 +91,7 @@ EOF # ---- cpmove-clean.tar.gz -------------------------------------------------- CLEAN_TMP="$(mktemp -d)" -trap 'rm -rf "$CLEAN_TMP" "$ALFA_TMP" 2>/dev/null || true' EXIT +trap 'rm -rf "$CLEAN_TMP" "$ALFA_TMP" "$MAIL_TMP" 2>/dev/null || true' EXIT build_common_tree "$CLEAN_TMP" tar -C "$CLEAN_TMP" -czf "$FIXTURES_DIR/cpmove-clean.tar.gz" "cpmove-$USER" @@ -108,6 +117,32 @@ ln -sf "/etc" "$ALFA_TMP/cpmove-$USER/homedir/public_html/$DOMAIN/ALFA_DATA/root tar -C "$ALFA_TMP" -czf "$FIXTURES_DIR/cpmove-alfa.tar.gz" "cpmove-$USER" echo "wrote $FIXTURES_DIR/cpmove-alfa.tar.gz ($(stat -c%s "$FIXTURES_DIR/cpmove-alfa.tar.gz") bytes)" +# ---- cpmove-mail.tar.gz --------------------------------------------------- + +MAIL_TMP="$(mktemp -d)" +build_common_tree "$MAIL_TMP" +MROOT="$MAIL_TMP/cpmove-$USER/homedir" +mkdir -p "$MROOT/etc/$DOMAIN" +printf 'alice:x:1:1::/home/%s/mail/%s/alice:/bin/false\nempty:x:1:1::/home/%s/mail/%s/empty:/bin/false\n' \ + "$USER" "$DOMAIN" "$USER" "$DOMAIN" > "$MROOT/etc/$DOMAIN/passwd" +# Not real hashes: the importer image never reads these, it only extracts. +printf 'alice:$6$fixturesaltfixtu$notarealhash:19000::::::\nempty:$6$fixturesaltfixtu$notarealhash:19000::::::\n' \ + > "$MROOT/etc/$DOMAIN/shadow" +for box in alice empty; do + mkdir -p "$MROOT/mail/$DOMAIN/$box"/{cur,new,tmp} +done +mkdir -p "$MROOT/mail/$DOMAIN/alice/.Archive"/{cur,new,tmp} +printf 'From: a@example.net\nSubject: one\n\nhello\n' \ + > "$MROOT/mail/$DOMAIN/alice/cur/1700000000.M1P1.host,S=40:2,S" +printf 'From: b@example.net\nSubject: two\n\narchived\n' \ + > "$MROOT/mail/$DOMAIN/alice/.Archive/cur/1700000001.M2P1.host,S=43:2," +printf 'Archive\n' > "$MROOT/mail/$DOMAIN/alice/subscriptions" +# DANGEROUS symlink inside the mail tree: stripped whether or not mail is included. +ln -sf "/etc" "$MROOT/mail/$DOMAIN/alice/.evil" + +tar -C "$MAIL_TMP" -czf "$FIXTURES_DIR/cpmove-mail.tar.gz" "cpmove-$USER" +echo "wrote $FIXTURES_DIR/cpmove-mail.tar.gz ($(stat -c%s "$FIXTURES_DIR/cpmove-mail.tar.gz") bytes)" + echo "" echo "fixtures built:" ls -la "$FIXTURES_DIR" diff --git a/tests/run-tests.sh b/tests/run-tests.sh new file mode 100755 index 0000000..0170ac5 --- /dev/null +++ b/tests/run-tests.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# +# run-tests.sh — host-side regression tests for the sandbox scripts. +# +# Runs extract.sh and scan-files.php straight from the checkout (no image +# build): needs bash, php-cli, python3 and GNU tar. clamscan is replaced by +# a stub on PATH that records its argv and reports fixed "FOUND" hits, so +# the scan-files.php plumbing (exclude flags, quarantine, counts) is tested +# without ClamAV or signatures. The full image is covered by the docker +# commands in CONTRIBUTING.md. +# +# Run: bash tests/run-tests.sh (exit 0 = all pass) + +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FIX="$REPO/tests/fixtures" +# Under /tmp: scan-files.php sets a container open_basedir that allows /tmp. +WORK="$(mktemp -d -p /tmp cpi-tests.XXXXXX)" +trap 'rm -rf "$WORK"' EXIT + +FAILS=0 +pass() { printf ' PASS %s\n' "$1"; } +fail() { printf ' FAIL %s\n' "$1"; FAILS=$((FAILS + 1)); } +check() { if eval "$2"; then pass "$1"; else fail "$1"; fi; } + +bash "$REPO/tests/build-fixtures.sh" >/dev/null + +# Copied under /tmp: scan-symlinks.php opens the tarball under the same open_basedir. +cp "$FIX/cpmove-mail.tar.gz" "$WORK/" +MAILTAR="$WORK/cpmove-mail.tar.gz" +U=cpmove-testuser +D=example.com + +# ---- extract.sh: default (no opt-in) skips the mail tree ------------------- +echo "extract.sh without IMPORT_INCLUDE_MAIL" +OUT="$WORK/ex-default" +LOG="$WORK/ex-default.log" +env -u IMPORT_INCLUDE_MAIL bash "$REPO/scripts/extract.sh" "$MAILTAR" "$OUT" testuser "$WORK/ex-default.actions.json" >"$LOG" 2>&1 +check "default: homedir/mail not extracted" "[[ ! -e '$OUT/$U/homedir/mail' ]]" +check "default: homedir/etc//passwd still extracted" "[[ -f '$OUT/$U/homedir/etc/$D/passwd' ]]" +check "default: log says the mail tree was skipped" "grep -q 'skipping mail tree' '$LOG'" + +# ---- extract.sh: opt-in includes it ----------------------------------------- +echo "extract.sh with IMPORT_INCLUDE_MAIL=true" +OUT="$WORK/ex-mail" +LOG="$WORK/ex-mail.log" +IMPORT_INCLUDE_MAIL=true bash "$REPO/scripts/extract.sh" "$MAILTAR" "$OUT" testuser "$WORK/ex-mail.actions.json" >"$LOG" 2>&1 +check "opt-in: INBOX message extracted" "[[ -f '$OUT/$U/homedir/mail/$D/alice/cur/1700000000.M1P1.host,S=40:2,S' ]]" +check "opt-in: subfolder message extracted" "[[ -f '$OUT/$U/homedir/mail/$D/alice/.Archive/cur/1700000001.M2P1.host,S=43:2,' ]]" +check "opt-in: subscriptions extracted" "[[ -f '$OUT/$U/homedir/mail/$D/alice/subscriptions' ]]" +check "opt-in: empty Maildir kept with cur/new/tmp" "[[ -d '$OUT/$U/homedir/mail/$D/empty/cur' && -d '$OUT/$U/homedir/mail/$D/empty/new' && -d '$OUT/$U/homedir/mail/$D/empty/tmp' ]]" +check "opt-in: DANGEROUS symlink inside the mail tree stripped" "[[ ! -e '$OUT/$U/homedir/mail/$D/alice/.evil' && ! -L '$OUT/$U/homedir/mail/$D/alice/.evil' ]]" +check "opt-in: the strip is recorded as an action" "grep -q 'homedir/mail/$D/alice/.evil' '$WORK/ex-mail.actions.json'" +check "opt-in: log says the mail tree was included" "grep -q 'including mail tree' '$LOG'" +check "opt-in: no 'skipping mail tree' line" "! grep -q 'skipping mail tree' '$LOG'" + +echo "extract.sh with IMPORT_INCLUDE_MAIL=1 (only \"true\" opts in)" +OUT="$WORK/ex-one" +IMPORT_INCLUDE_MAIL=1 bash "$REPO/scripts/extract.sh" "$MAILTAR" "$OUT" testuser "$WORK/ex-one.actions.json" >/dev/null 2>&1 +check "IMPORT_INCLUDE_MAIL=1: mail tree skipped" "[[ ! -e '$OUT/$U/homedir/mail' ]]" + +# ---- scan-files.php with a stub clamscan ------------------------------------ +# The stub reports one hit inside the mail tree and one in public_html. +STUBBIN="$WORK/bin" +mkdir -p "$STUBBIN" +cat > "$STUBBIN/clamscan" <<'STUB' +#!/usr/bin/env bash +printf '%s\n' "$@" > "$CLAMSCAN_ARGS_OUT" +dir="${!#}" +echo "$dir/cpmove-testuser/homedir/mail/example.com/alice/cur/1700000000.M1P1.host,S=40:2,S: Test.Mail.Sig FOUND" +echo "$dir/cpmove-testuser/homedir/public_html/index.php: Test.Php.Sig FOUND" +STUB +chmod +x "$STUBBIN/clamscan" + +run_scan() { # [extra args] + local ex="$1" tag="$2"; shift 2 + mkdir -p "$WORK/q-$tag" + CLAMSCAN_ARGS_OUT="$WORK/clam-$tag.args" PATH="$STUBBIN:$PATH" \ + php "$REPO/scripts/scan-files.php" --extract "$ex" --quarantine "$WORK/q-$tag" \ + --report "$WORK/scan-$tag.json" --import-id "t-$tag" "$@" 2>"$WORK/scan-$tag.log" +} +jq_php() { php -r '$r = json_decode(file_get_contents($argv[1]), true); echo json_encode($r[$argv[2]] ?? null);' "$1" "$2"; } + +echo "scan-files.php --skip-mail (mail tree extracted)" +cp -a "$WORK/ex-mail" "$WORK/scan-skip" +run_scan "$WORK/scan-skip" skip --skip-mail +# Expected argv entry: the extract dir with ERE metacharacters escaped +# (the only one mktemp's path can hold is "."), then the mail-tree pattern. +SKIPDIR="$WORK/scan-skip" +EXCL="--exclude-dir=^${SKIPDIR//./\\.}/cpmove-[^/]+/homedir/mail(/.*)?\$" +if grep -qxF -- "$EXCL" "$WORK/clam-skip.args"; then + pass "--skip-mail: clamscan gets --exclude-dir for the mail tree" +else + fail "--skip-mail: clamscan gets --exclude-dir for the mail tree (want $EXCL; got: $(tr '\n' ' ' < "$WORK/clam-skip.args"))" +fi +check "--skip-mail: a hit inside the mail tree is NOT quarantined (message stays in the Maildir)" \ + "[[ -f '$WORK/scan-skip/$U/homedir/mail/$D/alice/cur/1700000000.M1P1.host,S=40:2,S' ]]" +check "--skip-mail: a hit outside the mail tree is still quarantined" \ + "[[ ! -e '$WORK/scan-skip/$U/homedir/public_html/index.php' && -f '$WORK/q-skip/$U/homedir/public_html/index.php' ]]" +check "--skip-mail: report files_quarantined = 1" "[[ \$(jq_php '$WORK/scan-skip.json' files_quarantined) == 1 ]]" +check "--skip-mail: report mail_scan_skipped = true" "[[ \$(jq_php '$WORK/scan-skip.json' mail_scan_skipped) == true ]]" +NONMAIL=$(cd "$WORK/scan-skip" && find . -type f ! -path "./$U/homedir/mail/*" | wc -l) +check "--skip-mail: files_scanned counts only non-mail files ($NONMAIL, after the quarantine move)" \ + "[[ \$(jq_php '$WORK/scan-skip.json' files_scanned) == $((NONMAIL + 1)) ]]" +check "--skip-mail: the skip is logged with its reason" "grep -q 'NOT scanning the mail tree' '$WORK/scan-skip.log'" + +echo "scan-files.php without --skip-mail (unchanged behaviour)" +cp -a "$WORK/ex-mail" "$WORK/scan-noskip" +run_scan "$WORK/scan-noskip" noskip +check "no flag: no --exclude-dir passed" "! grep -q -- '--exclude-dir' '$WORK/clam-noskip.args'" +check "no flag: report mail_scan_skipped = false" "[[ \$(jq_php '$WORK/scan-noskip.json' mail_scan_skipped) == false ]]" +check "no flag: both hits quarantined" "[[ \$(jq_php '$WORK/scan-noskip.json' files_quarantined) == 2 ]]" + +echo +if [[ $FAILS -eq 0 ]]; then echo "ALL PASS"; else echo "$FAILS FAILURE(S)"; fi +exit $(( FAILS == 0 ? 0 : 1 ))