9 Commits
Author SHA1 Message Date
shadowdaoandClaude Opus 5.5 36d4949d34 extract: own-account links judged re-rooted, normalized, for the trusted username only (N2-1 review 2)
- 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>
2026-10-20 10:51:17 -07:00
shadowdaoandClaude Opus 5.5 72b8fa9d08 extract: post-extract realpath check of every symlink; classifier refuses link chains (N2-1 review)
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>
2026-10-20 10:47:04 -07:00
shadowdaoandClaude Opus 5.5 a226b496a2 scan-symlinks: relative targets escaping the archive or the homedir are DANGEROUS (N2-1)
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>
2026-10-20 10:42:15 -07:00
shadowdaoandClaude Opus 5.5 4a43d26de7 extract: mail-tree policy covers ANY top-level dir, not just cpmove-* (I-1)
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>
2026-10-20 09:59:41 -07:00
shadowdaoandClaude Opus 5.5 d4a63fba0a extract: verify always runs, failures remove the extract, walk fails closed
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>
2026-10-20 09:18:34 -07:00
shadowdaoandClaude Opus 5.5 52b1e656c3 extract: refuse parser-differential archives; post-extract link check is the authority
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>
2026-10-20 09:13:26 -07:00
Claude (bootstrap)andClaude Opus 4.7 db78a36935 sanitize-dont-refuse: strip dangerous symlinks via tar --exclude
cpanel-importer Build and Push / Build-and-Push (push) Successful in 1m10s
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
Claude (bootstrap)andClaude Opus 4.7 60a232c54a scan-symlinks: tighten DANGEROUS prefix list to actual destruction class
cpanel-importer Build and Push / Build-and-Push (push) Successful in 1m27s
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>
2026-05-31 10:07:23 -07:00
Claude (bootstrap)andClaude Opus 4.7 5487dfc8f1 Initial bootstrap: cpanel-importer sanitization sandbox
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>
2026-05-30 19:56:57 -07:00