Claude (bootstrap) db78a36935
All checks were successful
cpanel-importer Build and Push / Build-and-Push (push) Successful in 1m10s
sanitize-dont-refuse: strip dangerous symlinks via tar --exclude
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>
2026-05-31 11:13:57 -07:00

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:

  1. extracts the cpmove tarball into a tmpfs scratch dir (after a pre-extract symlink scan),
  2. runs ClamAV (with SaneSecurity PHP-malware rules) over every file, quarantining hits,
  3. rewrites ENGINE=MyISAMENGINE=InnoDB in every .sql dump,
  4. runs a WordPress content scan on each WP dump and refuses dumps with high-confidence malware signals (e.g. siteurl pointing at a non-customer domain),
  5. rsyncs the cleaned tree to /host/sanitized/<importid>/,
  6. 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
Description
Hardened sandbox container for sanitizing cpmove tarballs before WHP import. ClamAV+SaneSecurity malware scan, MyISAM->InnoDB conversion, WP content scan, symlink protection.
Readme MIT 120 KiB
Languages
PHP 60.8%
Shell 28.8%
Dockerfile 10.4%