feat: data sanitization — passwords, emails, sessions (#129)
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 7s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 9s
Branch Cleanup / Delete merged branch (pull_request) Successful in 3s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 6s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 54s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 33s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 7s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 9s
Branch Cleanup / Delete merged branch (pull_request) Successful in 3s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 6s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 54s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 33s
New "Data Sanitization" fieldset on profile form with four options: - Sanitize User Passwords: replaces all bcrypt hashes with invalid sentinel - Preserve Super Admin: keeps Super Users group passwords intact - Sanitize User Emails: replaces with user123@sanitized.example.com - Clear Session Data: excludes #__session table data (default: on) DatabaseDumper sanitizes rows inline during dump — both in-memory and file-streaming paths. Super admin detection uses group_id=8 from #__user_usergroup_map with static caching. Use cases: sharing backups, creating demo/staging sites, GDPR compliance. Partial #129 (Part 2 — restore script password reset — tracked separately)
This commit is contained in:
@@ -101,6 +101,54 @@
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="sanitization" label="COM_MOKOJOOMBACKUP_FIELDSET_SANITIZATION">
|
||||
<field
|
||||
name="sanitize_passwords"
|
||||
type="radio"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS_DESC"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="preserve_super_admin"
|
||||
type="radio"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
showon="sanitize_passwords:1"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="sanitize_emails"
|
||||
type="radio"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS_DESC"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="sanitize_sessions"
|
||||
type="radio"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="sidebar" label="COM_MOKOJOOMBACKUP_FIELDSET_STATUS">
|
||||
<field
|
||||
name="id"
|
||||
|
||||
@@ -139,6 +139,17 @@ COM_MOKOJOOMBACKUP_MOKORESTORE_NONE="None"
|
||||
COM_MOKOJOOMBACKUP_MOKORESTORE_WRAPPED="Wrapped (inside backup ZIP)"
|
||||
COM_MOKOJOOMBACKUP_MOKORESTORE_STANDALONE="Standalone (separate restore.php)"
|
||||
|
||||
; Data Sanitization
|
||||
COM_MOKOJOOMBACKUP_FIELDSET_SANITIZATION="Data Sanitization"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS="Sanitize User Passwords"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS_DESC="Replace all user password hashes with an invalid value. Users will not be able to log in with the restored backup without resetting their password. Ideal for sharing backups, creating demo/staging sites, or GDPR compliance."
|
||||
COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN="Preserve Super Admin Password"
|
||||
COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN_DESC="Keep the password for Super Users (group ID 8) intact. You will still be able to log in as a Super Admin after restoring."
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS="Sanitize User Emails"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS_DESC="Replace all user email addresses with dummy values (user123@sanitized.example.com). Prevents accidental emails being sent to real users from a cloned/staging site. Super admin emails are preserved if 'Preserve Super Admin' is enabled."
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS="Clear Session Data"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS_DESC="Exclude active session data from the backup. This logs out all users and prevents session hijacking when the backup is restored on another server. Enabled by default."
|
||||
|
||||
; Exclusion filter fields
|
||||
COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DIRS="Exclude Directories"
|
||||
COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DIRS_DESC="Browse and check directories to exclude from file backup. You can also type paths manually."
|
||||
|
||||
@@ -40,6 +40,10 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
|
||||
`remote_keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload',
|
||||
`encryption_password` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'AES-256 archive encryption password (blank = no encryption)',
|
||||
`include_mokorestore` VARCHAR(20) NOT NULL DEFAULT '0' COMMENT 'MokoRestore mode: 0=none, 1=wrapped, standalone',
|
||||
`sanitize_passwords` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Replace user password hashes with invalid value',
|
||||
`preserve_super_admin` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep super admin password when sanitizing',
|
||||
`sanitize_emails` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Replace user emails with dummy values',
|
||||
`sanitize_sessions` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Skip session table data',
|
||||
`notify_email` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Comma-separated notification emails',
|
||||
`notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs',
|
||||
`notify_on_success` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- MokoSuiteBackup 01.39.02 — Data sanitization columns
|
||||
|
||||
ALTER TABLE `#__mokosuitebackup_profiles`
|
||||
ADD COLUMN `sanitize_passwords` TINYINT(1) NOT NULL DEFAULT 0 AFTER `include_mokorestore`,
|
||||
ADD COLUMN `preserve_super_admin` TINYINT(1) NOT NULL DEFAULT 1 AFTER `sanitize_passwords`,
|
||||
ADD COLUMN `sanitize_emails` TINYINT(1) NOT NULL DEFAULT 0 AFTER `preserve_super_admin`,
|
||||
ADD COLUMN `sanitize_sessions` TINYINT(1) NOT NULL DEFAULT 1 AFTER `sanitize_emails`;
|
||||
@@ -137,7 +137,19 @@ class BackupEngine
|
||||
if ($profile->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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user