extract: opt-in mail tree (IMPORT_INCLUDE_MAIL=true) + mail_included marker

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>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-20 09:04:44 -07:00
1 parent 08b995a29c
commit 8217397154
5 files changed
+111 -22

No files matched your search

+32 -5
View File
@@ -7,7 +7,11 @@
* for v1.1 expansion; see CONTRIBUTING.md for how to wire one in.
*
* Usage:
* scan-files.php --extract <dir> --quarantine <dir> --report <out.json> --import-id <id>
* scan-files.php --extract <dir> --quarantine <dir> --report <out.json> --import-id <id> [--skip-mail]
*
* --skip-mail: do not scan (or count, or quarantine anything in) the
* cpmove-<user>/homedir/mail tree. entrypoint.sh passes it when the mail tree
* was extracted for WHP's mailbox import (IMPORT_INCLUDE_MAIL=true).
*
* Exit codes:
* 0 — scan completed (regardless of how many hits)
@@ -28,10 +32,10 @@ require __DIR__ . '/lib/safety-net.php';
const SCANNER_VERSION = '1.0.0';
$opts = getopt('', ['extract:', 'quarantine:', 'report:', 'import-id:']);
$opts = getopt('', ['extract:', 'quarantine:', 'report:', 'import-id:', 'skip-mail']);
foreach (['extract', 'quarantine', 'report', 'import-id'] as $k) {
if (!isset($opts[$k])) {
fwrite(STDERR, "usage: scan-files.php --extract <dir> --quarantine <dir> --report <out.json> --import-id <id>\n");
fwrite(STDERR, "usage: scan-files.php --extract <dir> --quarantine <dir> --report <out.json> --import-id <id> [--skip-mail]\n");
exit(2);
}
}
@@ -39,6 +43,15 @@ $extractDir = rtrim($opts['extract'], '/');
$quarantineDir = rtrim($opts['quarantine'], '/');
$reportPath = $opts['report'];
$importId = $opts['import-id'];
$skipMail = array_key_exists('skip-mail', $opts);
// The mail tree is copied verbatim into the customer's own mailbox, and
// WHP verifies the copy message-by-message against what it packaged. So it
// is NOT scanned: quarantining a message would silently drop mail, and
// ClamAV over a multi-GB maildir would dominate the run. PCRE form for the
// PHP walk, POSIX ERE form for clamscan --exclude-dir.
$mailDirPcre = '#\A' . preg_quote($extractDir, '#') . '/cpmove-[^/]+/homedir/mail(?:/|\z)#';
$mailDirEre = '^' . preg_replace('/[.\[\]()*+?{}|^$\\\\]/', '\\\\$0', $extractDir) . '/cpmove-[^/]+/homedir/mail(/.*)?$';
if (!is_dir($extractDir)) {
fwrite(STDERR, "scan-files: extract dir does not exist: $extractDir\n");
@@ -48,6 +61,9 @@ if (!is_dir($extractDir)) {
@mkdir($quarantineDir, 0750, true);
fwrite(STDERR, "scan-files: starting (extract=$extractDir, quarantine=$quarantineDir)\n");
if ($skipMail) {
fwrite(STDERR, "scan-files: NOT scanning the mail tree (cpmove-*/homedir/mail): it is copied verbatim into the customer's own mailbox and verified against the source; scanning would quarantine messages out of it and multi-GB maildirs would dominate the run\n");
}
// -- v1.0 cleaner registry (intentionally empty) ----------------------------
//
@@ -75,7 +91,8 @@ $cleaners = [];
// Output format per line on a hit:
// /tmp/extract/foo/bar.php: Some.Signature.Name FOUND
$cmd = sprintf(
'clamscan --infected --recursive --no-summary --stdout %s 2>/dev/null',
'clamscan --infected --recursive --no-summary --stdout %s%s 2>/dev/null',
$skipMail ? '--exclude-dir=' . escapeshellarg($mailDirEre) . ' ' : '',
escapeshellarg($extractDir)
);
@@ -99,6 +116,12 @@ while (($line = fgets($fh)) !== false) {
// Defensive: shouldn't happen with our invocation.
continue;
}
if ($skipMail && preg_match($mailDirPcre, $path)) {
// Belt: --exclude-dir should have kept clamscan out of the mail
// tree; never move a message out of a Maildir regardless.
fwrite(STDERR, "scan-files: WARN ignoring a hit inside the mail tree: $path\n");
continue;
}
$hits[] = ['path' => $path, 'signature' => $sig];
}
pclose($fh);
@@ -156,12 +179,15 @@ $rdi = new RecursiveDirectoryIterator(
$extractDir,
FilesystemIterator::SKIP_DOTS | FilesystemIterator::CURRENT_AS_PATHNAME
);
$filter = new RecursiveCallbackFilterIterator($rdi, function ($pathname, $key, $iterator) use (&$skippedLinks) {
$filter = new RecursiveCallbackFilterIterator($rdi, function ($pathname, $key, $iterator) use (&$skippedLinks, $skipMail, $mailDirPcre) {
// $pathname is a string when CURRENT_AS_PATHNAME is set.
if (is_link($pathname)) {
$skippedLinks++;
return false;
}
if ($skipMail && preg_match($mailDirPcre, $pathname)) {
return false; // not scanned, so not counted as scanned
}
return true;
});
$it = new RecursiveIteratorIterator($filter, RecursiveIteratorIterator::LEAVES_ONLY);
@@ -276,6 +302,7 @@ $report = [
'files_clean' => max(0, $filesScanned - count($hits)),
'files_cleaned' => $cleaned,
'files_quarantined' => $quarantined,
'mail_scan_skipped' => $skipMail,
'actions' => $actions,
];