├── .gitignore ├── LICENSE ├── README.md ├── composer.json └── src ├── Commands ├── MaskData.php └── ScanTables.php ├── Configs └── config.php ├── DataMaskingServiceProvider.php ├── Exceptions └── PrimaryKeyNotFoundException.php └── Traits └── ConfirmableTrait.php /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /public/hot 3 | /public/storage 4 | /storage/*.key 5 | /storage/.DS_Store 6 | /vendor 7 | /.idea 8 | /.vscode 9 | /.vagrant 10 | /.DS_Store 11 | Homestead.json 12 | Homestead.yaml 13 | npm-debug.log 14 | yarn-error.log 15 | .env 16 | .phpstorm.meta.php 17 | _ide_helper.php 18 | _ide_helper_models.php -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 y-ui 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # laravel-data-masking 2 | 3 | [![Latest Stable Version](https://poser.pugx.org/y-ui/laravel-data-masking/v/stable)](https://packagist.org/packages/y-ui/laravel-data-masking) 4 | [![Total Downloads](https://poser.pugx.org/y-ui/laravel-data-masking/downloads)](https://packagist.org/packages/y-ui/laravel-data-masking) 5 | [![Latest Unstable Version](https://poser.pugx.org/y-ui/laravel-data-masking/v/unstable)](https://packagist.org/packages/y-ui/laravel-data-masking) 6 | [![License](https://poser.pugx.org/y-ui/laravel-data-masking/license)](https://packagist.org/packages/y-ui/laravel-data-masking) 7 | 8 | ## Installation 9 | 10 | composer require --dev y-ui/laravel-data-masking ^1.0 11 | 12 | ## Configuration 13 | 14 | If you’re on Laravel 5.5 or later the package will be auto-discovered. 15 | 16 | Otherwise you will need to manually configure it in your `config/app.php` and add the following to the providers array: 17 | ```php 18 | \Yui\DataMasking\DataMaskingServiceProvider::class, 19 | ``` 20 | 21 | ## Usage 22 | ### Simple usage 23 | 24 | 1.scan database and generate config file `config/data-masking.php` 25 | ```shell 26 | php artisan data:scan 27 | ``` 28 | 29 | 2.Edit config file `data-masking.php` 30 | 31 | 3.Execute 32 | ```shell 33 | php artisan data:mask 34 | ``` 35 | 36 | ### Options 37 | 38 | #Specify the name of the table to scan 39 | php artisan data:scan --tables=users,orders 40 | 41 | #Update only data that meets the criteria 42 | php artisan data:mask --tables=users --where='id>100' 43 | 44 | 45 | ### Config File 46 | You can customize any character 47 | 48 | 'name' => '' nothing to do 49 | 'name' => '*:1-' Tom => *** Replace all characters 50 | 'name' => '0:2-4' William => W000iam 51 | 'name' => '0:3-5' Tom => To000 52 | 'phone' => '*:4-7' 18666666666 => 186****6666 53 | 'phone' => '123:4-' 18666666666 => 18612312312 54 | 'phone' => '*:1~2' 18666666666 => 1********66 Keep the first and end character 55 | 'email' => 'email:3-' production@google.com => pr********@google.com Replace only the characters before @ 56 | 'name' => 'text:Alex' everything => Alex Replace all value to text 57 | 58 | You can also use faker to replace column 59 | 60 | 'address' => 'faker:address' 61 | 'date' => "faker:date($format = 'Y-m-d', $max = 'now')" 62 | 'name' => 'faker:name' 63 | 64 | If there is a field with a content of 0-9, you want to set it all to 2 65 | ```php 66 | 'cloumn_name' => '2:1-' //This is faster than using faker 67 | ``` 68 | 69 | Faker wiki [https://github.com/fzaninotto/Faker](https://github.com/fzaninotto/Faker) 70 | 71 | If you want to use chinese, open `config/app.php` add `'faker_locale' => 'zh_CN'`, 72 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "y-ui/laravel-data-masking", 3 | "description": "A mysql data masking tool with Laravel", 4 | "type": "library", 5 | "license": "MIT", 6 | "require": { 7 | "php": ">=7.0.0", 8 | "doctrine/dbal": "^2.5" 9 | }, 10 | "require-dev": { 11 | "php": ">=7.0.0" 12 | }, 13 | "authors": [ 14 | { 15 | "name": "刘正伟" 16 | } 17 | ], 18 | "autoload": { 19 | "psr-4": { 20 | "Yui\\DataMasking\\": "src/" 21 | } 22 | }, 23 | "autoload-dev": { 24 | "psr-4": { 25 | "Yui\\DataMasking\\": "src/" 26 | } 27 | }, 28 | "minimum-stability": "dev", 29 | "extra": { 30 | "laravel": { 31 | "providers": [ 32 | "Yui\\DataMasking\\DataMaskingServiceProvider" 33 | ] 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Commands/MaskData.php: -------------------------------------------------------------------------------- 1 | startTime = microtime(true); 57 | 58 | $this->config = config('data-masking'); 59 | 60 | } 61 | 62 | /** 63 | * Execute the console command. 64 | * 65 | * @return mixed 66 | * @throws PrimaryKeyNotFoundException 67 | */ 68 | public function handle() 69 | { 70 | if (! $this->confirmToProceed()) { 71 | return; 72 | } 73 | 74 | $this->makeOptions(); 75 | $this->testConfig(); 76 | $this->filterConfig(); 77 | 78 | foreach ($this->config as $tableName => $table) { 79 | $table = array_filter($table); 80 | 81 | $sqlAttributes = $fakerAttributes = []; 82 | $index = 0; 83 | foreach ($table as $column => $value) { 84 | [$char, $range] = explode(":", $value); 85 | if (strtolower($char) == 'faker') { 86 | $fakerAttributes[$column] = $range; 87 | } else { 88 | $sqlAttributes[$column] = $this->patternToSql($column, $char, $range, $index); 89 | } 90 | 91 | $index++; 92 | } 93 | 94 | if (!empty($sqlAttributes) || !empty($fakerAttributes)) { 95 | $this->line("updating $tableName"); 96 | } 97 | 98 | if ($sqlAttributes) { 99 | $this->updateWithSqlFunction($tableName, $sqlAttributes); 100 | } 101 | 102 | if ($fakerAttributes) { 103 | $this->updateWithFaker($tableName, $fakerAttributes); 104 | } 105 | } 106 | 107 | $this->showInfo(); 108 | } 109 | 110 | /** 111 | * 纯SQL更新,最快的方式 112 | * 113 | * @param $tableName 114 | * @param $attributes 115 | * @throws PrimaryKeyNotFoundException 116 | */ 117 | public function updateWithSqlFunction($tableName, $attributes) 118 | { 119 | $join = []; 120 | if (!empty($this->emailSqls)) { 121 | $primaryKey = $this->getPrimaryKey($tableName); 122 | foreach ($this->emailSqls as $sql) { 123 | $join[] = str_ireplace(['{table_name}', '{primary_key}'], [$tableName, $primaryKey], $sql); 124 | } 125 | 126 | $this->emailSqls = []; 127 | } 128 | 129 | $join = implode(' ', $join); 130 | 131 | $sql = "update $tableName $join set " . implode(',', $attributes) . $this->where; 132 | $this->info($sql); 133 | DB::statement($sql); 134 | } 135 | 136 | /** 137 | * 用faker生成数据更新,慢 138 | * 139 | * @param $tableName 140 | * @param $attributes 141 | * @throws PrimaryKeyNotFoundException 142 | */ 143 | public function updateWithFaker($tableName, $attributes) 144 | { 145 | $limit = 1000; 146 | $page = 0; 147 | $primaryKey = $this->getPrimaryKey($tableName); 148 | $faker = \Faker\Factory::create(config('app.faker_locale')); 149 | 150 | while (true) { 151 | $offset = $limit * $page; 152 | $this->line("updating $tableName $offset-" . ($page+1)*$limit); 153 | $sql = "select $primaryKey from $tableName {$this->where} order by $primaryKey asc limit $offset,$limit"; 154 | $records = DB::select($sql); 155 | foreach ($records as $record) { 156 | $attr = []; 157 | foreach ($attributes as $attributeName => $code) { 158 | $result = ''; 159 | eval('$result = addcslashes($faker->' . $code . ', "\'");'); 160 | $attr[] = "$attributeName='$result'"; 161 | } 162 | 163 | DB::update("update $tableName set " . implode(',', $attr) . " where $primaryKey=" . $record->{$primaryKey}); 164 | } 165 | $page++; 166 | 167 | if (count($records) < $limit) break; 168 | } 169 | } 170 | 171 | /** 172 | * 获取表的主键 173 | * 174 | * @param $tableName 175 | * @return mixed 176 | * @throws PrimaryKeyNotFoundException 177 | */ 178 | public function getPrimaryKey($tableName) 179 | { 180 | $re = DB::select("SHOW KEYS FROM $tableName WHERE Key_name = 'PRIMARY'"); 181 | if (!$re) throw new PrimaryKeyNotFoundException($tableName); 182 | 183 | return $re[0]->Column_name; 184 | } 185 | 186 | /** 187 | * 构造SQL 188 | * 189 | * @param $column 190 | * @param $char 191 | * @param $range 192 | * @return string 193 | */ 194 | public function patternToSql($column, $char, $range, $index) 195 | { 196 | $targetColumn = $column; 197 | if (strtolower($char) == 'email') { 198 | $char = '*'; 199 | $targetColumn = self::EMAIL_KEY_PREFIX . $index; 200 | $tempTable = 'marsk_tmp_table_' . $index; 201 | $this->emailSqls[] = "inner join(select {primary_key},substr(email,1 ,instr(email, '@')-1) as $targetColumn from {table_name}) as $tempTable on $tempTable.{primary_key}={table_name}.{primary_key}"; 202 | } 203 | if (strpos($range, '~') !== false) { 204 | return $this->keepTheFirstAndLastSql($column, $char, $range, $targetColumn); 205 | } 206 | 207 | if (strtolower($char) == 'text') { 208 | return $this->keepFixedTextSql($column, $char, $range, $targetColumn); 209 | } 210 | 211 | return $this->keepIntervalSql($column, $char, $range, $targetColumn); 212 | } 213 | 214 | /** 215 | * 直接替换成固定字符串的SQL 216 | *` 217 | * @param $column 218 | * @param $char 219 | * @param $range 220 | * @param $targetColumn 221 | * @return string 222 | */ 223 | public function keepFixedTextSql($column, $char, $range, $targetColumn) 224 | { 225 | return "$column='$range'"; 226 | } 227 | 228 | /** 229 | * 脱敏区间字符的SQL 230 | * 231 | * @param $column 232 | * @param $char 233 | * @param $range 234 | * @return string 235 | */ 236 | public function keepIntervalSql($column, $char, $range, $targetColumn) 237 | { 238 | [$start, $end] = explode('-', $range); 239 | 240 | $left = "substr(`$targetColumn`, 1, $start-1)"; 241 | 242 | if ($end) { 243 | $maskStr = "'" . str_pad($char, ($end - $start + 1), $char) . "'"; 244 | $right = "substr(`$targetColumn`, $end+1, char_length(`$targetColumn`))"; 245 | } else { 246 | $maskStr = "rpad('$char', char_length(`$targetColumn`) - $start + 1, '$char')"; 247 | $right = "''"; 248 | } 249 | 250 | $emailRight = $this->getEmailRight($column, $targetColumn); 251 | 252 | return "$column=concat($left, $maskStr, $right, $emailRight)"; 253 | } 254 | 255 | /** 256 | * 返回保留首尾字符的SQL 257 | * 258 | * @param $column 259 | * @param $char 260 | * @param $range 261 | * @return string 262 | */ 263 | public function keepTheFirstAndLastSql($column, $char, $range, $targetColumn) 264 | { 265 | [$start, $end] = explode('~', $range); 266 | $end = $end ?: 0; 267 | 268 | $left = "substr(`$targetColumn`, 1, $start)"; 269 | $total = $start + $end; 270 | 271 | if ($end) { 272 | $right = "if(char_length(`$targetColumn`)>$total,substr(`$targetColumn`, char_length(`$targetColumn`)-$end+1, char_length(`$targetColumn`)), substr(`$targetColumn`, $start+1, char_length(`$targetColumn`)-$start))"; 273 | $maskStr = "if(char_length(`$targetColumn`)>$total, rpad('$char', char_length(`$targetColumn`) - $start - $end, '$char'), '')"; 274 | } else { 275 | $maskStr = "rpad('$char', char_length(`$targetColumn`) - $start, '$char')"; 276 | $right = "''"; 277 | } 278 | 279 | $emailRight = $this->getEmailRight($column, $targetColumn); 280 | 281 | return "$column=concat($left, $maskStr, $right, $emailRight)"; 282 | } 283 | 284 | /** 285 | * 邮件格式时需要填充的字符 286 | * 287 | * @param $column 288 | * @param $targetColumn 289 | * @return string 290 | */ 291 | public function getEmailRight($column, $targetColumn) 292 | { 293 | if (stripos($targetColumn, self::EMAIL_KEY_PREFIX) !== false) { 294 | return "substr($column, instr(`$column`, '@'), 200)"; 295 | } 296 | 297 | return "''"; 298 | } 299 | 300 | 301 | /** 302 | * 测试配置文件是否正确 303 | */ 304 | public function testConfig() 305 | { 306 | $errors = []; 307 | foreach ($this->config as $tableName => $table) { 308 | $table = array_filter($table); 309 | foreach ($table as $column => $value) { 310 | $t = explode(":", $value); 311 | if (count($t) !== 2) { 312 | $errors[] = "Unknown pattern in table $tableName column $column:$value"; 313 | } 314 | 315 | if (strtolower($t[0]) != 'faker' && preg_match('/\w{1,}:\d{1,}-\d{0,}/', $value) === false) { 316 | $errors[] = "Unknown pattern in table $tableName column $column:$value"; 317 | } 318 | } 319 | } 320 | 321 | if (!empty($errors)) { 322 | foreach ($errors as $error) { 323 | $this->error($error); 324 | } 325 | 326 | exit(0); 327 | } 328 | } 329 | 330 | /** 331 | * 按条件过滤要脱敏的表 332 | * 333 | */ 334 | protected function filterConfig() 335 | { 336 | if (!empty($this->tables)) { 337 | $tablesOnly = array_combine($this->tables, $this->tables); 338 | $this->config = array_intersect_key($this->config, $tablesOnly); 339 | } 340 | } 341 | 342 | /** 343 | * 处理参数 344 | */ 345 | protected function makeOptions() 346 | { 347 | if (!empty($this->option('where'))) { 348 | $this->where = " where " . $this->option('where'); 349 | } 350 | 351 | if (!empty($this->option('tables'))) { 352 | $this->tables = explode(',', $this->option('tables')); 353 | } 354 | } 355 | 356 | 357 | /** 358 | * 显示内存和时间信息 359 | */ 360 | protected function showInfo() 361 | { 362 | $this->info('time cost: ' . round(microtime(true) - $this->startTime, 2) . ' seconds'); 363 | $this->info('max memory usage: ' . round(memory_get_peak_usage(true)/1024/1024, 2) . 'M'); 364 | } 365 | 366 | } 367 | -------------------------------------------------------------------------------- /src/Commands/ScanTables.php: -------------------------------------------------------------------------------- 1 | startTime = microtime(true); 43 | 44 | } 45 | 46 | /** 47 | * Execute the console command. 48 | * 49 | * @return mixed 50 | */ 51 | public function handle() 52 | { 53 | $options = $this->options(); 54 | 55 | if (file_exists(config_path(self::CONFIG_FILE))) { 56 | $confirm = $this->confirm('The config file has exists, make sure to overwrite?'); 57 | if (!$confirm) { 58 | $this->comment('Command Cancelled!'); 59 | return; 60 | } 61 | } 62 | 63 | if (!empty($options['tables'])) { 64 | $tables = explode(',', $options['tables']); 65 | } else { 66 | $tables = DB::connection()->getDoctrineSchemaManager()->listTableNames(); 67 | } 68 | 69 | $configs = []; 70 | 71 | foreach ($tables as $table) { 72 | $columns = Schema::getColumnListing($table); 73 | 74 | if (($index = array_search('id', $columns)) !== false) { 75 | unset($columns[$index]); 76 | } 77 | 78 | $configs[$table] = array_combine($columns, array_pad([], count($columns), '')); 79 | } 80 | 81 | $txt = var_export($configs, true); 82 | 83 | $txt = str_replace(["=> \n array (", ')', ' ', 'array ('], ["=> [", ']', ' ', '['], $txt); 84 | 85 | $txt = file_get_contents(__DIR__ . '/../Configs/config.php') . 'return ' . $txt . ';'; 86 | 87 | file_put_contents(config_path(self::CONFIG_FILE), $txt); 88 | 89 | $this->showInfo(); 90 | } 91 | 92 | 93 | 94 | /** 95 | * 显示内存和时间信息 96 | */ 97 | protected function showInfo() 98 | { 99 | $this->info('time cost: ' . round(microtime(true) - $this->startTime, 2) . ' seconds'); 100 | $this->info('max memory usage: ' . round(memory_get_peak_usage(true)/1024/1024, 2) . 'M'); 101 | } 102 | 103 | } 104 | -------------------------------------------------------------------------------- /src/Configs/config.php: -------------------------------------------------------------------------------- 1 | '' nothing to do 9 | | 'name' => '*:1-' Tom => *** Replace all characters 10 | | 'name' => '0:2-3' William => W000iam 11 | | 'name' => '0:3-5' Tom => To000 12 | | 'phone' => '*:4-7' 18666666666 => 186****6666 13 | | 'phone' => '123:4-' 18666666666 => 18612312312 14 | | 'phone' => '*:1~2' 18666666666 => 1********66 Keep the first and end character 15 | | 'email' => 'email:3-' production@google.com => pr********@google.com Replace only the characters before @ 16 | | You can customize any character 17 | | 18 | | You can also use faker to replace column 19 | | 'address' => 'faker:address' 20 | | 'date' => "faker:date($format = 'Y-m-d', $max = 'now')" 21 | | Faker wiki https://github.com/fzaninotto/Faker 22 | | 23 | | If you want to use chinese, open config/app.php add 'faker_locale' => 'zh_CN', 24 | | 25 | */ 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/DataMaskingServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 30 | __DIR__.'/config.php' => config_path('runningtime.php'), 31 | ]);*/ 32 | 33 | $this->registerCommand(); 34 | } 35 | 36 | public function registerCommand() 37 | { 38 | $commands = [ 39 | ScanTables::class, 40 | MaskData::class, 41 | ]; 42 | 43 | $this->commands($commands); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Exceptions/PrimaryKeyNotFoundException.php: -------------------------------------------------------------------------------- 1 | getDefaultConfirmCallback() : $callback; 21 | 22 | $shouldConfirm = $callback instanceof Closure ? call_user_func($callback) : $callback; 23 | 24 | if ($shouldConfirm) { 25 | $this->alert($warning); 26 | 27 | $confirmed = $this->confirm('Do you really wish to run this command?'); 28 | 29 | if (! $confirmed) { 30 | $this->comment('Command Cancelled!'); 31 | 32 | return false; 33 | } 34 | } 35 | 36 | return true; 37 | } 38 | 39 | /** 40 | * Get the default confirmation callback. 41 | * 42 | * @return \Closure 43 | */ 44 | protected function getDefaultConfirmCallback() 45 | { 46 | return function () { 47 | return $this->getLaravel()->environment() === 'production'; 48 | }; 49 | } 50 | } 51 | --------------------------------------------------------------------------------