Security review of 52b1e65:
1. (HIGH) tarfile's stream could enumerate differently from GNU tar. Now
extract.sh captures tar's exit code (`|| TAR_RC=$?`), ALWAYS runs the
post-extract verify on whatever tar wrote, and any failure (tar,
verify, either pre-extract scan) removes $DEST before exiting non-zero.
The classifier also refuses (exit 3) on any stream exception and when
its member count differs from GNU tar's own escape-quoted listing.
Measured: a lone zero block mid-archive stops BOTH readers at the same
member (no -i is used), so this is a guard, not a known differential.
2. (MEDIUM) os.walk swallowed unreadable directories. verify now passes
onerror (recorded => fail) and refuses any directory that is not
readable+searchable. 52b1e65 exited 0 on the unreadable fixture.
3. (MEDIUM) a refused tree stayed in the bind-mounted output. fail_extract
opens up 0000 dirs (find -P -type d, never follows links), then
rm -rf -- "$DEST".
Fixtures: cpmove-bad-{unreadable,tarfail,truncated}; a fake tar that lists
one extra member proves the count check. 14 checks fail on 52b1e65.
Docker: unreadable / tarfail runs end with status failed, failed_stage
extract and no extract-work left in /host/sanitized/<id>.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Security review of 4788ca4 (inline python link classifier):
1. Names like cpmove-x/./homedir/mail/..., homedir//mail, homedir/../homedir
or /cpmove-x/... were matched differently by the classifier than by tar,
so a mail-tree symlink could escape the strip (measured: the dot, dslash
and abs fixtures extracted with exit 0 on 4788ca4).
2. A newline in a member name split one --exclude-from pattern in two.
Moved the policy to scripts/lib/mail-tree-links.py:
- scan (pre-extract, an optimisation): REFUSES the archive (exit 3) on any
control character in a member name or link target, or any non-canonical
member name / hard-link target (absolute, '..', or normpath() differing
after one leading './'). Symlink targets are not canonical-checked:
ordinary cPanel links (../public_html) are legitimate outside the mail
tree, and every symlink inside it is stripped anyway.
- verify (post-extract, the authority): lstat-walks each mail tree; exit 4
on any symlink, or any regular file whose st_nlink exceeds its names
inside that same mail tree (a link leaving it). In-tree Dovecot hard-link
copies (maildir_copy_with_hardlinks) are kept; a blanket nlink>1 refusal
would drop real mail.
- Fixtures: cpmove-bad-{dot,dslash,dotdot,abs,newline}; verify tests for
symlinks and hard links into/out of/between mail trees surviving the
pre-filter. 14 of the new checks fail on 4788ca4.
Only in IMPORT_INCLUDE_MAIL=true mode; the default path is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Security review of 8217397: with IMPORT_INCLUDE_MAIL=true only
DANGEROUS-classified (absolute system-path) symlinks were stripped, so a
relative link (../../../../etc/shadow, ../other-domain/box) or a hard link
into/out of the mail tree survived into the extract.
- From the archive's member list (python tarfile, exact names/types):
exclude EVERY symlink under cpmove-*/homedir/mail, and every hard link
that crosses the mail-tree boundary (either direction), spans two
accounts' mail trees, or has ".." in its target. Recorded as
stripped_mail_link actions (deduped against the DANGEROUS strips).
- Hard links with name AND target inside the same mail tree are KEPT:
Dovecot copies messages between folders as hard links
(maildir_copy_with_hardlinks, default yes), so they are real mail.
- Belt: after extract, any symlink left under the mail tree is removed and
logged.
- Fixtures: relative file/folder symlinks, an in-tree Dovecot-style hard
link, hard links crossing in and out (cpmove-mail-xlink.tar.gz). 6 new
checks fail on 8217397's extract.sh.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Skeleton for the cpanel-importer Docker container — a one-shot
sandbox the WHP panel invokes BEFORE extracting a customer cpmove
tarball. See cpanel-import-container-spec.md (in /workspace/) for the
full design.
What this ships in v1.0:
- Dockerfile: almalinux:10-minimal + PHP 8.4 (Remi) + ClamAV 1.4 +
SaneSecurity Foxhole.PHP rules + tar/mariadb-client/rsync. Runs as
UID 999 (whp-import) via the panel-side --user 999:999 flag.
- scripts/entrypoint.sh: validates env, runs (optional) freshclam,
drives extract -> scan-files -> scan-dbs -> rsync -> report.json.
- scripts/extract.sh + scripts/lib/scan-symlinks.php: pre-extract
symlink scan ported standalone from
web-files/libs/CpanelBackupImporter.php (the existing 2026-05-29
whp02 destruction-vector fix). Aborts with exit 3 before tar runs
if any DANGEROUS symlink is found.
- scripts/scan-files.php: ClamAV walk + classify-and-action. v1.0
ships with an empty cleaner registry — every hit is
QUARANTINE_ONLY. Cleaner hooks are stubbed for v1.1.
- scripts/scan-dbs.php: regex MyISAM -> InnoDB rewrite (always
applied), WordPress identification, and ONE WP content scan check
(siteurl_external_domain). v1.1 will grow the check set.
- scripts/lib/safety-net.php: container-narrow open_basedir
allow-list, much tighter than the panel-side one.
- .gitea/workflows/build-push.yaml: builds + smoke-tests +
PHP-syntax-checks + bash-syntax-checks before pushing to
repo.anhonesthost.net/cloud-hosting-platform/cpanel-importer.
- tests/build-fixtures.sh: builds cpmove-clean.tar.gz (benign WP
dump) and cpmove-alfa.tar.gz (the ALFA-shell symlink-to-/etc
vector) for local end-to-end testing.
- README.md / CONTRIBUTING.md: docker-run invocation, bind-mount
catalog, report.json schema, how to add a cleaner pattern or a WP
scan signature.
Local acceptance test results:
- clean fixture -> status=completed, 3 MyISAM->InnoDB, no flags, 0
- ALFA fixture -> exit 1, status=failed, failed_stage=extract,
"tarball contains dangerous symlinks; aborting" on stderr
- compromised-siteurl fixture -> imported_into_new_server=false,
.flagged file written, summary_for_panel.show_alert=true
Image size: 197 MB compressed (gzipped docker save), ~397 MB unique
layers extracted. Well under the spec's 600 MB compressed / 1.2 GB
extracted budget.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>