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:
1 parent
08b995a29c
commit
8217397154
5 files changed
+111
-22
No files matched your search
+32
-5
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user