- strip-escaping-links.py: the /home[N]/<user>/ exemption ran on the RAW
target (`/home/u/../../etc/shadow` was exempt) and on usernames taken
from the archive's own top-dir names (an archive could name itself
cpmove-root). Now only the username WHP passes (extract.sh <username> =
IMPORT_USERNAME, validated) is trusted; the target must be normalized; and
it is RE-ROOTED at the link's <top>/homedir in the extract and kept only
if that resolves (realpath strict) inside it. No trusted name: nothing
exempt.
- scan-symlinks.php: same trusted-name rule; a non-normalized absolute
target is DANGEROUS outright (never prefix-compared).
- tests: /home/u/../../etc/shadow, /home/u/./x, re-rooted dangling,
re-rooted through an escaping link, another user's /home, an
archive-named user (cpmove-root) via extract.sh, no/invalid trusted name.
8 checks fail on 72b8fa9.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lexical relative-target check missed chains: `a -> b/../..` with
`b -> ../..`, or `l1 -> l2/../../..`, each resolve inside the archive
lexically but above the extract through the other link.
- scripts/lib/strip-escaping-links.py (new, the authority): after tar,
lstat-walk the WHOLE extract (never following links), realpath(strict)
every symlink, and remove + record (stripped_escaping_symlink) any that
resolves outside the extract or to it, dangles or loops. Repeats to a
fixed point; a walk error fails the extract. Own-account absolute
/home[N]/<user>/ targets are kept (they cannot resolve in the sandbox and
resolve only inside the customer's site container on WHP).
- scan-symlinks.php: two passes; a relative link whose resolution passes
through another symlink entry of the archive is DANGEROUS.
- fixtures/tests: cpmove-chain (both shapes) and a hand-built tree for the
post-extract pass (chains, /etc, dangling .bak sidecar, escaping .bak,
loop, link to the top). 3 chain checks fail on a226b49.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A relative symlink was skipped as safe (`if ($target[0] !== '/') continue`),
so `cpmove-u/sds -> ../../../../root/.my.cnf` survived extraction and WHP
parsed the host file as the subdomain list. Resolve relative targets
lexically from the link's own directory: reaching or climbing above the
archive root, or a link under <top>/homedir/ resolving outside that homedir,
is DANGEROUS (stripped by extract.sh). Same rule as WHP's
CpanelBackupImporter::relativeSymlinkEscape().
The clean fixture's about-shortcut (../public_html/about.txt resolved
outside homedir/) now points at public_html/about.txt as its comment meant.
New cpmove-relesc fixture + checks (5 fail on 4a43d26).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review I-1: WHP's CpanelArchivePaths::selectTopDir() takes the first
non-hidden top dir of any name (cpmove-<u>, cPanel's user download
backup-<date>_<u>/, acct/ ...), but the link classifier, the post-extract
verify and scan-files' --skip-mail only matched cpmove-*. Measured by the
reviewer: an acct/ archive kept a ../../etc symlink in its Maildir, and
ClamAV would quarantine messages OUT of a backup-* Maildir (=> Verified
with mail missing).
- mail-tree-links.py: MAIL = ^([^/]+)/homedir/mail; verify walks every
top-level dir, and fails on a top-level or homedir symlink instead of
walking through it. Covering every top dir is a superset of whichever
one WHP picks, so multi-top-dir archives need no special case.
- scan-files.php: --skip-mail patterns use [^/]+ for the top dir.
- M-7: entrypoint refuses IMPORT_INCLUDE_MAIL=true as uid 0 (the verify's
os.access check is meaningless for root; WHP runs --user 999:999).
- M-2: the mail-tree refusals tell the operator the same backup may
import with "Import without email".
- M-4: run-tests.sh checks the env/marker contract against a WHP checkout
when WHP_REPO is set.
- Fixtures cpmove-acct / cpmove-backupdir (the mail fixture under acct/
and backup-10.20.2026_10-00-00_testuser/). 13 checks fail on d4a63fb.
Docker: backup-* Maildir EICAR not quarantined, public_html one is;
--user 0 with the opt-in => status failed.
The default (no-opt-in) mail exclude keeps its historical cpmove-* scope.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
WHP's cPanel mailbox import needs cpmove-*/homedir/mail, which extract.sh
always excluded: in sanitization-container mode every mailbox was queued
with no Maildir and "verified" empty (B5, whp02 canary 2026-10-20).
- IMPORT_INCLUDE_MAIL=true (exactly "true"; default off) keeps the mail
tree in the extract. Default behaviour is unchanged. The DANGEROUS
symlink strips still apply inside the mail tree (scan-symlinks.php lists
every archive entry).
- scan-files.php --skip-mail (passed by entrypoint.sh with the opt-in):
clamscan --exclude-dir for the mail tree, the file count skips it, and a
hit inside it is never quarantined. Logged with the reason: mail is
copied verbatim into the customer's own mailbox and verified against the
source, and multi-GB maildirs would dominate the run.
- report.json gains "mail_included" (true only with the opt-in) so the
panel can tell this image from an older one, which never writes the key.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Surfaced on whp02 alsacorp retry: scan-dbs.php hit PHP fatal at line 86
"Allowed memory size of 134217728 bytes exhausted (tried to allocate
5488440384 bytes)" while loading alsacorp_alsa1.sql via
file_get_contents. The dump is multi-GB (typical for WooCommerce stores
with media metadata); the 128MB-default PHP memory_limit + the 2GB
cgroup on the container both fail well below the actual file size.
Rewrote the per-DB pass as a streaming loop over 4MB chunks:
- engine_swap_chunk: same `\bENGINE=MyISAM\b` regex, mutates a
per-DB counter via reference so the per-chunk counts accumulate
into a single myisam_to_innodb total.
- is_wp_chunk_scan: OR-folds the four WP fingerprint regexes
(CREATE TABLE *_options, *_posts, *_users + the
'siteurl|home|template|stylesheet' sentinel) into a state dict;
any chunk that flips a flag from false to true keeps it true for
the rest of the file. Caller AND-folds at finalization.
- wp_options_chunk_scan: extracts (option_name, option_value)
tuples from INSERT INTO options statements as they pass through.
First occurrence wins so we keep the live value, not later
duplicates.
- wp_content_scan_from_values: extracted the finalization logic
from the legacy wp_content_scan() so the streaming path can
submit a pre-built option-values map instead of re-scanning the
full string.
Per-chunk carry: a 128-byte buffer at the end of each chunk is held
back and prepended to the next chunk so a pattern split across a
chunk boundary (e.g. "ENGINE=" at byte 4194302, "MyISAM" at byte
4194304) is still seen by the regex. 128 bytes is generous for our
patterns (longest is "ENGINE = MyISAM" with whitespace flex).
Output goes to a `<db>.sql.tmp` first, then renamed to
`<db>.sql{,.flagged}` once we know the flag verdict — avoids a
partial file if the scan dies mid-stream.
Legacy `engine_swap`, `is_wordpress_dump`, and the unused
`wp_content_scan`+`extract_wp_options` are kept in place for the
small-file path (none of them currently called from the new
streaming loop, but they're public-ish helpers the next dbsanitize
revision could reuse).
Resident memory now bounded to <16 MB per DB regardless of input
file size — should handle the 30 GB+ outliers we'll inevitably see.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Container run on darkside completed end-to-end successfully (170,753
files scanned, 78 symlinks skipped via filter, 65 quarantined, walk
errors = 0) — but the import log was flooded with PHP Warnings on
every cpmove-internal symlink whose absolute target points outside
the container's open_basedir allow-list:
PHP Warning: is_link(): open_basedir restriction in effect.
File(/host/sanitized/.../cybercoveconsulting.com/wp-content/db.php)
is not within the allowed path(s): (/host:/tmp:...)
The actual code path was correct — is_link() still returns true when
warning, so the filter callback properly skipped these. But the noise
made the streamed [container] log on the panel side unreadable
(hundreds of warning lines per real signal line).
Root cause: PHP's open_basedir check normalizes via realpath() even
for is_link/is_file. cpmove symlinks like:
db.php -> /home/<user>/<addon>.com/wp-content/db.php
access-logs -> /usr/local/apache/domlogs/<user>
cpanel-styled -> /usr/local/cpanel/base/frontend/.../glass
have absolute targets that don't exist anywhere in the container
(no /home, no /usr/local), so realpath() can't normalize them under
any allow-list entry. PHP fires Warning, returns the lstat answer
anyway, and our filter handles the skip correctly.
Fix: a scoped set_error_handler around the walk that suppresses ONLY
E_WARNINGs containing 'open_basedir restriction'. Non-open_basedir
warnings still surface. The handler is restored immediately after the
file-count loop, so subsequent stages (clamscan output parsing,
quarantine actions) keep the default error reporting.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WHP does not import cPanel mailbox data (mail-import is a panel-side
roadmap item, not a sandbox-mode step). Extracting + ClamAV-scanning
the mail tree wastes time and disk: on real customer accounts the mail
dir often dwarfs everything else (10+ GB of historical maildir/mbox),
and clamscan has to walk every message.
Appended to the existing tar --exclude-from list (where we already
strip DANGEROUS-classified symlinks) so the existing plumbing covers
both. tar's fnmatch globs handle nested mail subdirs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cPanel cpmove tarballs contain symlinks with absolute targets pointing at
the SOURCE server's filesystem (e.g. addon docroots symlinked to
/home/<user>/<addondomain>.com/ on the cPanel host). After extract into
the container, those symlinks dangle — their targets don't exist in the
container's namespace AND are not under any open_basedir-allowed prefix.
PHP's SplFileInfo::isFile() (called from the RecursiveIteratorIterator
file-count loop) follows symlinks. The realpath check against
open_basedir then fires on the symlink TARGET, not the link path, and
throws RuntimeException mid-iteration — aborting the entire scan
without writing report.json. Surfaced on darkside import as:
PHP Fatal error: Uncaught RuntimeException: SplFileInfo::isFile():
open_basedir restriction in effect. File(/host/sanitized/.../
cybercoveconsulting.com/wp-content/db.php) is not within the allowed
path(s): (/host:/tmp:/opt/whp:/scripts:/var/lib/clamav:...)
Fix is two-layered:
1. RecursiveCallbackFilterIterator pre-filters symlinks via is_link()
before they reach hasChildren/isFile. is_link is open_basedir-safe
(it stats the link itself, doesn't resolve). Skipped count is
reported on STDERR so operators see what was skipped.
2. try/catch around the per-entry isFile() as a defense-in-depth
layer — if any other fs op throws mid-walk (race, planted device
node, etc.) we count it as a walk_error and continue, not abort.
Note that clamscan already walks the extract tree on its own pass and
its default symlink posture is "don't follow" — the same posture we
want here. Symlink-as-file would also be useless to quarantine (it's
a 0-byte fs entry whose target is the actual artifact). Skipping
symlinks therefore doesn't miss anything.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
rc=137 OOM kill triaged on whp02 darkside import. dmesg confirmed:
memory: usage 2097100kB, limit 2097152kB, failcnt 132
oom_kill_process ... task=bash uid=999
Root cause: extract.sh untars the cpmove into EXTRACT_DIR which was
/tmp/extract — a tmpfs mount (RAM-backed). The container's
--memory 2g cgroup ceiling counts tmpfs writes against RSS, so the
3 GB cpmove decompressing into tmpfs hit the limit at ~7s into tar
and the kernel killed the bash process running extract.sh.
Fix is structural, not a memory bump: the disk-backed bind mount
at /host/sanitized (mapped to /var/lib/whp/cpanel-importer-extract
on host) has effectively unlimited capacity and doesn't count against
the cgroup memory limit. Moving the working dirs there sidesteps the
OOM class entirely.
Layout change:
EXTRACT_DIR /tmp/extract -> $SANITIZED_DIR/extract-work
WORK_DIR /tmp/sanitized -> $SANITIZED_DIR/work
Two ripple changes:
- The old rsync_out stage cross-filesystem-copied ~10 GB from tmpfs
to /host/sanitized/<id>/extracted. That's now a same-filesystem
`mv` (constant-time rename) since extract-work IS already inside
/host/sanitized/<id>/. Stage renamed to finalize_layout for
clarity; pre-existing wipe of extracted/ + mysql/ guards against
partial-run residue.
- The stripped-symlinks actions sidecar moved to /tmp explicitly
(entrypoint.sh passes the 4th arg to extract.sh) so finalize's
rename doesn't (a) carry a dotfile into the cleaned tree the
panel imports and (b) move it out from under write_report's read.
Also fixes the unrelated-but-cosmetic freshclam warning by cd'ing to
/var/lib/clamav (the configured DatabaseDirectory, tmpfs writable)
before invoking freshclam in a subshell. The "Can't create
freshclam.dat in /opt/whp" errors were because /opt/whp is the
container WORKDIR which lives on the read-only rootfs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shifts the sandbox's symlink handling from "refuse the whole tarball"
to "drop the dangerous entries from extraction and record them as
quarantine actions". This is what sandbox mode is supposed to do —
make malicious cpmoves safe to import rather than gate-keeping them.
Three coordinated changes:
1. scan-symlinks.php — exit 0 even when DANGEROUS findings exist. The
JSON report is the source of truth; the caller decides what to do
with it. Usage/IO errors still exit 2. STDERR still names each
finding (now "STRIP X -> Y" instead of "refusing tarball") so the
streamed [container] log on the panel side surfaces them.
2. extract.sh — reads the scan-symlinks report, builds a
newline-delimited exclude list of DANGEROUS archive_paths, and
passes it to `tar --exclude-from=`. The stripped entries never
reach the filesystem; tar skips them silently. Also writes a small
JSON sidecar at $EXTRACT_DIR/.cpanel-importer-stripped-symlinks.json
describing each strip-action so the merge step can surface them in
report.json without re-parsing scan-symlinks output.
3. entrypoint.sh write_report — reads the sidecar, prepends each
stripped_dangerous_symlink action to the actions[] list, bumps
files_quarantined by the strip-count, and rewrites
summary_for_panel.alert_message to call them out distinctly:
"N dangerous symlink(s) stripped during extract; M files
quarantined; K cleaned in place. Customer site may have been
compromised at the source — recommend review."
Result on darkside: instead of the import failing on the ALFA
alfasymlink/root entry, that entry is silently skipped during
extract, recorded as `stripped_dangerous_symlink path=... target=/
reason=absolute target is root /`, and the rest of the tarball
extracts normally. Subsequent ClamAV scan + DB sanitization run
to completion; panel sees a verdict-completed import with the
stripped symlinks visible in the Sanitization Sandbox panel on the
results page.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous version of scan-symlinks.php was a verbatim port of the panel's
scanTarballForDangerousSymlinks(), which flagged every symlink whose
target sits under /etc, /usr, /bin, /sbin, /lib, /lib64, /var/lib,
/var/log, /var/cache, or /var/spool. That's the right posture for the
panel's pre-extract scan in DIRECT mode — refuse before extract — but
it makes the container REFUSE every cpmove that comes from a real
cPanel source server, including totally clean ones. Standard cPanel
accounts ship with stock symlinks like:
homedir/access-logs -> /usr/local/apache/domlogs/<user>
homedir/var/cpanel/styled/current_style
-> /usr/local/cpanel/base/frontend/...
homedir/.cpanel/email -> /usr/local/cpanel/...
homedir/etc -> /var/cpanel/userhomes/<user>/etc
Every customer tarball has 5-20 of these. Treating them as DANGEROUS
made the container abort with verdict=refused before extract.sh ever
ran. Surfaced on darkside import to whp02: scan-symlinks found
homedir/access-logs (a textbook cPanel symlink) and the import bombed.
The real destruction class — what ALFA TEaM Shell uses, what we saw
brick whp02 in May — is symlinks whose target is the exact filesystem
root or under one of the genuinely catastrophic system trees that
either escape the customer account or clobber boot/config/proc state:
/ exact root (the classic alfasymlink/root)
/etc config tampering, /etc/shadow exfil
/root root home dir
/boot bootloader / kernel
/proc process info / kernel knobs
/sys sysfs
/dev device nodes
Everything else (notably /usr, /var) becomes UNCERTAIN: reported in
the JSON output but doesn't refuse the tarball. With --cap-drop=ALL
--read-only --network none --user 999, a /usr-targeting symlink in
the container's sandbox can at worst dangle on extract; it can't
touch the host.
Co-Authored-By: Claude Opus 4.7 (1M context) <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>