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>
cpanel-importer
A sanitization sandbox for cPanel cpmove tarballs, run as a one-shot
Docker container before WHP imports a customer site.
It is not a full importer. The container:
- extracts the cpmove tarball into a tmpfs scratch dir (after a pre-extract symlink scan),
- runs ClamAV (with SaneSecurity PHP-malware rules) over every file, quarantining hits,
- rewrites
ENGINE=MyISAM→ENGINE=InnoDBin every.sqldump, - runs a WordPress content scan on each WP dump and refuses dumps with
high-confidence malware signals (e.g.
siteurlpointing at a non-customer domain), - rsyncs the cleaned tree to
/host/sanitized/<importid>/, - emits a JSON report describing every action taken.
The WHP panel reads /host/sanitized/<importid>/report.json after the
container exits and hands the cleaned files off to the existing
CpanelBackupImporter flow (Linux-user create, MySQL DB create, file
rsync, DNS push, container provision, etc.).
Full design: /workspace/cpanel-import-container-spec.md (also
checked in at docs/cpanel-import-container-spec.md when this repo is
mirrored to the panel).
Panel-side glue: /workspace/whp/web-files/libs/CpanelBackupImporter.php
web-files/api/cpanel-import-ajax.php+web-files/pages/cpanel-import-results.php.
How the panel invokes it
docker run \
--rm \
--name whp-cpanel-import-${IMPORT_ID} \
--network client-net \
--user 999:999 \
--cap-drop=ALL \
--security-opt=no-new-privileges \
--read-only \
--tmpfs /tmp:rw,nosuid,nodev,exec,size=4g \
--tmpfs /var/lib/clamav:rw,nosuid,nodev,size=512m \
--volume /docker/users/${USERNAME}/userfiles/${BACKUP_NAME}:/host/backup/${BACKUP_NAME}:ro \
--volume /docker/users/${USERNAME}/.cpanel-import-quarantine:/host/quarantine:rw \
--volume /docker/users/${USERNAME}/.cpanel-import-sanitized:/host/sanitized:rw \
--env IMPORT_ID=${IMPORT_ID} \
--env IMPORT_USERNAME=${USERNAME} \
--env IMPORT_BACKUP_FILE=/host/backup/${BACKUP_NAME} \
--env CLAMAV_REFRESH=true \
--memory=4g \
--memory-swap=4g \
--cpus=2 \
--pull=missing \
repo.anhonesthost.net/cloud-hosting-platform/cpanel-importer:2026.05.NNN
Container exits with status 0 on success, non-zero on any failure
(missing/unreadable backup, dangerous symlink found, scanner error).
Even on failure, /host/sanitized/<importid>/report.json is written
with "status": "failed" and the failing stage.
Bind-mount catalog
| Host path | Container path | Mode | Purpose |
|---|---|---|---|
/docker/users/<user>/userfiles/<tarball> |
/host/backup/<tarball> |
RO | the cpmove input |
/docker/users/<user>/.cpanel-import-quarantine/ |
/host/quarantine/ |
RW | files moved here on ClamAV hit |
/docker/users/<user>/.cpanel-import-sanitized/<importid>/ |
/host/sanitized/ |
RW | cleaned output the panel reads |
Anything not listed here is not visible to the container. No /etc,
no /usr, no /root, no /home, no docker.sock. The worker runs as
UID/GID 999 with --cap-drop=ALL --read-only.
report.json schema
Written to /host/sanitized/<importid>/report.json at the end of every
run, success or failure.
Success
{
"import_id": "import_abc123",
"status": "completed",
"scan_duration_seconds": 143,
"files_scanned": 28471,
"files_clean": 28432,
"files_cleaned": 0,
"files_quarantined": 39,
"actions": [
{
"path": "cpmove-testuser/homedir/public_html/example.com/ALFA_DATA/index.php",
"signature": "PHP.Webshell.ALFA",
"action": "quarantined",
"cleaner": null,
"backup": "/host/quarantine/import_abc123/cpmove-testuser/homedir/public_html/example.com/ALFA_DATA/index.php"
}
],
"databases": [
{
"dbname": "testuser_wp",
"size_bytes": 5393199573,
"engine_changes": {
"myisam_to_innodb": 17,
"row_format_dynamic_applied": 0,
"fulltext_indexes_dropped": 0
},
"wp_content_scan": {
"is_wordpress": true,
"flags": [
{
"severity": "high",
"code": "siteurl_external_domain",
"details": "wp_options.siteurl = \"http://evil.tld\" — host 'evil.tld' not in allowed domain list (example.com)"
}
]
},
"imported_into_new_server": false,
"flagged_sql_path": "/host/sanitized/import_abc123/mysql/testuser_wp.sql.flagged"
}
],
"summary_for_panel": {
"show_alert": true,
"alert_severity": "warning",
"alert_message": "39 files quarantined + 0 cleaned in place; 1 database(s) refused as compromised. ..."
}
}
Failure
{
"import_id": "import_abc123",
"status": "failed",
"failed_stage": "extract",
"error": "scan-symlinks.php exited non-zero — tarball contains DANGEROUS symlinks",
"scan_duration_seconds": 4,
"files": null,
"databases": null
}
failed_stage is one of: validate_env, freshclam, extract,
scan_files, scan_dbs, rsync_out, write_report.
Local development
# Build the image
docker build -t cpanel-importer:dev .
# Build the synthetic fixture tarballs
bash tests/build-fixtures.sh
# Run against the clean fixture
mkdir -p /tmp/test-quarantine /tmp/test-sanitized
docker run --rm \
-e IMPORT_ID=test \
-e IMPORT_USERNAME=testuser \
-e IMPORT_BACKUP_FILE=/host/backup/cpmove-clean.tar.gz \
-e CLAMAV_REFRESH=false \
-v "$(pwd)/tests/fixtures/cpmove-clean.tar.gz:/host/backup/cpmove-clean.tar.gz:ro" \
-v /tmp/test-quarantine:/host/quarantine \
-v /tmp/test-sanitized:/host/sanitized \
cpanel-importer:dev
cat /tmp/test-sanitized/test/report.json
# Run against the ALFA-symlink fixture — must exit non-zero with a
# "dangerous symlinks" message and report.json should have
# status=failed, failed_stage=extract.
docker run --rm \
-e IMPORT_ID=test-alfa \
-e IMPORT_USERNAME=testuser \
-e IMPORT_BACKUP_FILE=/host/backup/cpmove-alfa.tar.gz \
-e CLAMAV_REFRESH=false \
-v "$(pwd)/tests/fixtures/cpmove-alfa.tar.gz:/host/backup/cpmove-alfa.tar.gz:ro" \
-v /tmp/test-quarantine:/host/quarantine \
-v /tmp/test-sanitized:/host/sanitized \
cpanel-importer:dev \
&& echo "BUG: should have exited non-zero" \
|| echo "OK: refused dangerous tarball"
cat /tmp/test-sanitized/test-alfa/report.json
What is in this v1.0 vs. what is stubbed for v1.1+
| Feature | v1.0 | v1.1 |
|---|---|---|
| Pre-extract symlink scan | full port of scanTarballForDangerousSymlinks |
– |
| Hardened tar extract | yes | – |
| ClamAV + SaneSecurity Foxhole.PHP rules | yes | – |
| File classification | quarantine-on-every-hit | KNOWN_REMOVABLE + REMOVABLE_WITH_BACKUP cleaners |
| MyISAM → InnoDB rewrite | yes | – |
| WP identification | yes (wp_options + wp_posts + wp_users + sentinel) | – |
| WP content scan | siteurl_external_domain only | post_content script-injection, theme/stylesheet malware patterns, user_pass leaked-hash, Wordfence regex |
| ROW_FORMAT=DYNAMIC, FULLTEXT drop | stubbed (always 0) | yes |
| Sandboxed MariaDB-in-container for SQL transforms | not present (regex transforms only) | yes |
See CONTRIBUTING.md for how to add a cleaner pattern or a new WP scan
signature.
References
- Spec:
/workspace/cpanel-import-container-spec.md - Panel-side importer:
/workspace/whp/web-files/libs/CpanelBackupImporter.php - WHP panel
safety-net.php:/workspace/whp/web-files/includes/safety-net.php - Existing CI workflow for sibling project:
/workspace/cloud-apache-container/.gitea/workflows/build-push.yaml