diff --git a/source/packages/com_mokosuitebackup/forms/profile.xml b/source/packages/com_mokosuitebackup/forms/profile.xml index 3551ee0..fb069b9 100644 --- a/source/packages/com_mokosuitebackup/forms/profile.xml +++ b/source/packages/com_mokosuitebackup/forms/profile.xml @@ -101,6 +101,54 @@ /> +
+ + + + + + + + + + + + + + + + +
+
backup_type !== 'files') { $this->log('Starting database dump...'); $sqlTempFile = $this->backupDir . '/.database-' . $tag . '.sql'; - $dumper = new DatabaseDumper($excludeTables); + $sanitizePasswords = (bool) ($profile->sanitize_passwords ?? false); + $preserveSuperAdmin = (bool) ($profile->preserve_super_admin ?? false); + $sanitizeEmails = (bool) ($profile->sanitize_emails ?? false); + $sanitizeSessions = (bool) ($profile->sanitize_sessions ?? true); + $dumper = new DatabaseDumper($excludeTables, $sanitizePasswords, $preserveSuperAdmin, $sanitizeEmails, $sanitizeSessions); + + if ($sanitizePasswords) { + $this->log('User passwords will be sanitized' . ($preserveSuperAdmin ? ' (super admin preserved)' : '')); + } + + if ($sanitizeEmails) { + $this->log('User emails will be sanitized'); + } $dbSize = $dumper->dumpToFile($sqlTempFile); $archiver->addFile($sqlTempFile, 'database.sql'); $tablesCount = $dumper->getTablesCount(); diff --git a/source/packages/com_mokosuitebackup/src/Engine/DatabaseDumper.php b/source/packages/com_mokosuitebackup/src/Engine/DatabaseDumper.php index a1e1536..429e052 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/DatabaseDumper.php +++ b/source/packages/com_mokosuitebackup/src/Engine/DatabaseDumper.php @@ -27,12 +27,35 @@ class DatabaseDumper private int $tablesCount = 0; + /** @var bool Whether to sanitize user passwords */ + private bool $sanitizePasswords = false; + + /** @var bool Whether to preserve super admin password when sanitizing */ + private bool $preserveSuperAdmin = false; + + /** @var bool Whether to sanitize user emails */ + private bool $sanitizeEmails = false; + + /** @var bool Whether to clear session data */ + private bool $sanitizeSessions = false; + + /** Known invalid bcrypt hash used for sanitized passwords */ + private const SANITIZED_HASH = '$2y$10$SANITIZED.MOKOSUITEBACKUP.INVALID.HASH.DO.NOT.USE.000000'; + /** - * @param array $excludeTables Table names to exclude (with #__ prefix). - * Supports suffixes: :data-only, :structure-only. - * No suffix = exclude both (backward compatible). + * @param array $excludeTables Table names to exclude (with #__ prefix). + * @param bool $sanitizePasswords Replace user password hashes with invalid value + * @param bool $preserveSuperAdmin Keep super admin password when sanitizing + * @param bool $sanitizeEmails Replace user emails with sanitized placeholders + * @param bool $sanitizeSessions Skip session table data entirely */ - public function __construct(array $excludeTables = []) + public function __construct( + array $excludeTables = [], + bool $sanitizePasswords = false, + bool $preserveSuperAdmin = false, + bool $sanitizeEmails = false, + bool $sanitizeSessions = false + ) { foreach ($excludeTables as $entry) { if (str_ends_with($entry, ':data-only')) { @@ -43,6 +66,16 @@ class DatabaseDumper $this->excludeBoth[] = $entry; } } + + $this->sanitizePasswords = $sanitizePasswords; + $this->preserveSuperAdmin = $preserveSuperAdmin; + $this->sanitizeEmails = $sanitizeEmails; + $this->sanitizeSessions = $sanitizeSessions; + + /* If session sanitization is on, auto-exclude session table data */ + if ($sanitizeSessions) { + $this->excludeDataOnly[] = '#__session'; + } } /** @@ -154,6 +187,7 @@ class DatabaseDumper } foreach ($rows as $row) { + $this->sanitizeRow($row, $abstractName, $db); $values = []; foreach ($row as $value) { @@ -326,6 +360,7 @@ class DatabaseDumper } foreach ($rows as $row) { + $this->sanitizeRow($row, $abstractName, $db); $values = []; foreach ($row as $value) { @@ -351,6 +386,86 @@ class DatabaseDumper return filesize($filePath) ?: 0; } + /** + * Sanitize a row if it belongs to the users table and sanitization is enabled. + * + * Replaces the password column with an invalid hash so the backup + * cannot be used to extract user credentials. + */ + private function sanitizeRow(array &$row, string $abstractTable, object $db): void + { + if ($abstractTable !== '#__users') { + return; + } + + if (!$this->sanitizePasswords && !$this->sanitizeEmails) { + return; + } + + if ($this->sanitizeEmails && isset($row['email']) && isset($row['id'])) { + $userId = (int) $row['id']; + + /* Preserve super admin emails if preserving super admin */ + if (!$this->preserveSuperAdmin || !$this->isSuperAdmin($userId, $db)) { + $row['email'] = 'user' . $userId . '@sanitized.example.com'; + } + } + + if (!$this->sanitizePasswords || !isset($row['password'])) { + return; + } + + if ($this->preserveSuperAdmin && isset($row['id'])) { + if ($this->isSuperAdmin((int) $row['id'], $db)) { + return; + } + } + + $row['password'] = self::SANITIZED_HASH; + } + + /** + * Check if a user ID belongs to the Super Users group (group_id = 8). + */ + private function isSuperAdmin(int $userId, object $db): bool + { + static $superAdminIds = null; + + if ($superAdminIds === null) { + $prefix = $db->getPrefix(); + + try { + $db->setQuery( + $db->getQuery(true) + ->select('DISTINCT ' . $db->quoteName('user_id')) + ->from($db->quoteName($prefix . 'user_usergroup_map')) + ->where($db->quoteName('group_id') . ' = 8') + ); + $superAdminIds = array_map('intval', $db->loadColumn() ?: []); + } catch (\Throwable $e) { + $superAdminIds = []; + } + } + + return in_array($userId, $superAdminIds, true); + } + + /** + * Check if passwords were sanitized (for use by callers to log the action). + */ + public function isPasswordSanitizationEnabled(): bool + { + return $this->sanitizePasswords; + } + + /** + * Get the sentinel hash used for sanitized passwords. + */ + public static function getSanitizedHash(): string + { + return self::SANITIZED_HASH; + } + public function getTablesCount(): int { return $this->tablesCount;