diff --git a/app/Models/Asset.php b/app/Models/Asset.php index 07624af9fb..a977ec3816 100644 --- a/app/Models/Asset.php +++ b/app/Models/Asset.php @@ -226,7 +226,11 @@ class Asset extends Depreciable foreach ($this->model->fieldset->fields as $field) { - if ($field->format == 'BOOLEAN') { + // this just casts booleans that may come through as strings to an actual boolean type + // adding !$field->field_encrypted because when the encrypted value comes through it + // screws things up for the encrypted validation rules (and the encrypted string + // is not a valid boolean type) + if ($field->format == 'BOOLEAN' && !$field->field_encrypted) { $this->{$field->db_column} = filter_var($this->{$field->db_column}, FILTER_VALIDATE_BOOLEAN); } } diff --git a/app/Models/CustomField.php b/app/Models/CustomField.php index f1865d692f..0e8845cfb3 100644 --- a/app/Models/CustomField.php +++ b/app/Models/CustomField.php @@ -16,6 +16,7 @@ class CustomField extends Model UniqueUndeletedTrait; /** + * * Custom field predfined formats * * @var array diff --git a/app/Models/CustomFieldset.php b/app/Models/CustomFieldset.php index 7c9ad7dd81..f27b838647 100644 --- a/app/Models/CustomFieldset.php +++ b/app/Models/CustomFieldset.php @@ -3,12 +3,19 @@ namespace App\Models; use App\Rules\AlphaEncrypted; +use App\Rules\BooleanEncrypted; +use App\Rules\DateEncrypted; +use App\Rules\EmailEncrypted; +use App\Rules\IPEncrypted; +use App\Rules\IPv4Encrypted; +use App\Rules\IPv6Encrypted; +use App\Rules\MacEncrypted; use App\Rules\NumericEncrypted; +use App\Rules\RegexEncrypted; +use App\Rules\UrlEncrypted; use Gate; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Facades\Log; -use Illuminate\Validation\Rule; use Watson\Validating\ValidatingTrait; class CustomFieldset extends Model @@ -99,7 +106,7 @@ class CustomFieldset extends Model * @since [v3.0] * @return array */ - public function validation_rules() + public function validation_rules(): array { $rules = []; foreach ($this->fields as $field) { @@ -121,18 +128,65 @@ class CustomFieldset extends Model $rules[$field->db_column_name()] = $rule; - - // these are to replace the standard 'numeric' and 'alpha' rules if the custom field is also encrypted. - // the values need to be decrypted first, because encrypted strings are alphanumeric - if ($field->format === 'NUMERIC' && $field->field_encrypted) { + // this is to switch the rules to rules specially made for encrypted custom fields that decrypt the value before validating + if ($field->field_encrypted) { $numericKey = array_search('numeric', $rules[$field->db_column_name()]); - $rules[$field->db_column_name()][$numericKey] = new NumericEncrypted; + $alphaKey = array_search('alpha', $rules[$field->db_column_name()]); + $emailKey = array_search('email', $rules[$field->db_column_name()]); + $dateKey = array_search('date', $rules[$field->db_column_name()]); + $urlKey = array_search('url', $rules[$field->db_column_name()]); + $ipKey = array_search('ip', $rules[$field->db_column_name()]); + $ipv4Key = array_search('ipv4', $rules[$field->db_column_name()]); + $ipv6Key = array_search('ipv6', $rules[$field->db_column_name()]); + $macKey = array_search('regex:/^[a-fA-F0-9]{2}:[a-fA-F0-9]{2}:[a-fA-F0-9]{2}:[a-fA-F0-9]{2}:[a-fA-F0-9]{2}:[a-fA-F0-9]{2}$/', $rules[$field->db_column_name()]); + $booleanKey = array_search('boolean', $rules[$field->db_column_name()]); + // find objects in array that start with "regex:" + // collect because i couldn't figure how to do this + // with array filter and get keys out of it + $regexCollect = collect($rules[$field->db_column_name()]); + $regexKeys = $regexCollect->filter(function ($value, $key) { + return starts_with($value, 'regex:'); + })->keys()->values()->toArray(); + + switch ($field->format) { + case 'NUMERIC': + $rules[$field->db_column_name()][$numericKey] = new NumericEncrypted; + break; + case 'ALPHA': + $rules[$field->db_column_name()][$alphaKey] = new AlphaEncrypted; + break; + case 'EMAIL': + $rules[$field->db_column_name()][$emailKey] = new EmailEncrypted; + break; + case 'DATE': + $rules[$field->db_column_name()][$dateKey] = new DateEncrypted; + break; + case 'URL': + $rules[$field->db_column_name()][$urlKey] = new UrlEncrypted; + break; + case 'IP': + $rules[$field->db_column_name()][$ipKey] = new IPEncrypted; + break; + case 'IPV4': + $rules[$field->db_column_name()][$ipv4Key] = new IPv4Encrypted; + break; + case 'IPV6': + $rules[$field->db_column_name()][$ipv6Key] = new IPv6Encrypted; + break; + case 'MAC': + $rules[$field->db_column_name()][$macKey] = new MacEncrypted; + break; + case 'BOOLEAN': + $rules[$field->db_column_name()][$booleanKey] = new BooleanEncrypted; + break; + case starts_with($field->format, 'regex'): + foreach ($regexKeys as $regexKey) { + $rules[$field->db_column_name()][$regexKey] = new RegexEncrypted; + } + break; + } } - if ($field->format === 'ALPHA' && $field->field_encrypted) { - $alphaKey = array_search('alpha', $rules[$field->db_column_name()]); - $rules[$field->db_column_name()][$alphaKey] = new AlphaEncrypted; - } // add not_array to rules for all fields but checkboxes if ($field->element != 'checkbox') { diff --git a/app/Rules/AlphaEncrypted.php b/app/Rules/AlphaEncrypted.php index f4ed1d6c32..e13f677946 100644 --- a/app/Rules/AlphaEncrypted.php +++ b/app/Rules/AlphaEncrypted.php @@ -5,9 +5,11 @@ namespace App\Rules; use Closure; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Support\Facades\Crypt; +use Illuminate\Validation\Concerns\ValidatesAttributes; class AlphaEncrypted implements ValidationRule { + use ValidatesAttributes; /** * Run the validation rule. * @@ -18,7 +20,7 @@ class AlphaEncrypted implements ValidationRule try { $attributeName = trim(preg_replace('/_+|snipeit|\d+/', ' ', $attribute)); $decrypted = Crypt::decrypt($value); - if (!ctype_alpha($decrypted) && !is_null($decrypted)) { + if (!$this->validateAlpha($attributeName, $decrypted, 'ascii') && !is_null($decrypted)) { $fail(trans('validation.alpha', ['attribute' => $attributeName])); } } catch (\Exception $e) { diff --git a/app/Rules/BooleanEncrypted.php b/app/Rules/BooleanEncrypted.php new file mode 100644 index 0000000000..06fd370703 --- /dev/null +++ b/app/Rules/BooleanEncrypted.php @@ -0,0 +1,33 @@ +validateBoolean($attributeName, $decrypted) && !is_null($decrypted)) { + $fail(trans('validation.ipv6', ['attribute' => $attributeName])); + } + } catch (\Exception $e) { + report($e); + $fail(trans('general.something_went_wrong')); + } + } +} diff --git a/app/Rules/DateEncrypted.php b/app/Rules/DateEncrypted.php new file mode 100644 index 0000000000..73d5cd1b3e --- /dev/null +++ b/app/Rules/DateEncrypted.php @@ -0,0 +1,32 @@ +validateDate($attributeName, $decrypted) && !is_null($decrypted)) { + $fail(trans('validation.date', ['attribute' => $attributeName])); + } + } catch (\Exception $e) { + report($e); + $fail(trans('general.something_went_wrong')); + } + } +} diff --git a/app/Rules/EmailEncrypted.php b/app/Rules/EmailEncrypted.php new file mode 100644 index 0000000000..cd0e4ad289 --- /dev/null +++ b/app/Rules/EmailEncrypted.php @@ -0,0 +1,32 @@ +validateEmail($attribute, $decrypted, []) && !is_null($decrypted)) { + $fail(trans('validation.email', ['attribute' => $attributeName])); + } + } catch (\Exception $e) { + report($e); + $fail(trans('general.something_went_wrong')); + } + } +} diff --git a/app/Rules/IPEncrypted.php b/app/Rules/IPEncrypted.php new file mode 100644 index 0000000000..2d64546308 --- /dev/null +++ b/app/Rules/IPEncrypted.php @@ -0,0 +1,32 @@ +validateIp($attributeName, $decrypted) && !is_null($decrypted)) { + $fail(trans('validation.ip', ['attribute' => $attributeName])); + } + } catch (\Exception $e) { + report($e); + $fail(trans('general.something_went_wrong')); + } + } +} diff --git a/app/Rules/IPv4Encrypted.php b/app/Rules/IPv4Encrypted.php new file mode 100644 index 0000000000..57f36da49a --- /dev/null +++ b/app/Rules/IPv4Encrypted.php @@ -0,0 +1,32 @@ +validateIpv4($attributeName, $decrypted) && !is_null($decrypted)) { + $fail(trans('validation.ipv4', ['attribute' => $attributeName])); + } + } catch (\Exception $e) { + report($e); + $fail(trans('general.something_went_wrong')); + } + } +} diff --git a/app/Rules/IPv6Encrypted.php b/app/Rules/IPv6Encrypted.php new file mode 100644 index 0000000000..6bb44fb2d9 --- /dev/null +++ b/app/Rules/IPv6Encrypted.php @@ -0,0 +1,32 @@ +validateIpv6($attributeName, $decrypted) && !is_null($decrypted)) { + $fail(trans('validation.ipv6', ['attribute' => $attributeName])); + } + } catch (\Exception $e) { + report($e); + $fail(trans('general.something_went_wrong')); + } + } +} diff --git a/app/Rules/MacEncrypted.php b/app/Rules/MacEncrypted.php new file mode 100644 index 0000000000..c9b125a900 --- /dev/null +++ b/app/Rules/MacEncrypted.php @@ -0,0 +1,33 @@ +validateRegex($attributeName, $decrypted, ['/^[a-fA-F0-9]{2}:[a-fA-F0-9]{2}:[a-fA-F0-9]{2}:[a-fA-F0-9]{2}:[a-fA-F0-9]{2}:[a-fA-F0-9]{2}$/']) && !is_null($decrypted)) { + $fail(trans('validation.mac_address', ['attribute' => $attributeName])); + } + } catch (\Exception $e) { + report($e); + $fail(trans('general.something_went_wrong')); + } + } +} diff --git a/app/Rules/NumericEncrypted.php b/app/Rules/NumericEncrypted.php index f3cb3ba76e..ff551e0322 100644 --- a/app/Rules/NumericEncrypted.php +++ b/app/Rules/NumericEncrypted.php @@ -3,12 +3,16 @@ namespace App\Rules; use Closure; +use Egulias\EmailValidator\Validation\RFCValidation; use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Support\Facades\Crypt; +use Illuminate\Validation\Concerns\ValidatesAttributes; class NumericEncrypted implements ValidationRule { + use ValidatesAttributes; + /** * Run the validation rule. * @@ -16,11 +20,10 @@ class NumericEncrypted implements ValidationRule */ public function validate(string $attribute, mixed $value, Closure $fail): void { - try { $attributeName = trim(preg_replace('/_+|snipeit|\d+/', ' ', $attribute)); $decrypted = Crypt::decrypt($value); - if (!is_numeric($decrypted) && !is_null($decrypted)) { + if (!$this->validateNumeric($attributeName, $decrypted) && !is_null($decrypted)) { $fail(trans('validation.numeric', ['attribute' => $attributeName])); } } catch (\Exception $e) { diff --git a/app/Rules/RegexEncrypted.php b/app/Rules/RegexEncrypted.php new file mode 100644 index 0000000000..9a573f4b53 --- /dev/null +++ b/app/Rules/RegexEncrypted.php @@ -0,0 +1,36 @@ +first(); + $regex = $field->format; + $regex = str_replace('regex:', '', $regex); + try { + $attributeName = trim(preg_replace('/_+|snipeit|\d+/', ' ', $attribute)); + $decrypted = Crypt::decrypt($value); + if (!$this->validateRegex($attributeName, $decrypted, [$regex]) && !is_null($decrypted)) { + $fail(trans('validation.regex', ['attribute' => $attributeName])); + } + } catch (\Exception $e) { + report($e->getMessage()); + $fail(trans('general.something_went_wrong')); + } + } +} diff --git a/app/Rules/UrlEncrypted.php b/app/Rules/UrlEncrypted.php new file mode 100644 index 0000000000..61c5f51c90 --- /dev/null +++ b/app/Rules/UrlEncrypted.php @@ -0,0 +1,32 @@ +validateUrl($attributeName, $decrypted, []) && !is_null($decrypted)) { + $fail(trans('validation.url', ['attribute' => $attributeName])); + } + } catch (\Exception $e) { + report($e); + $fail(trans('general.something_went_wrong')); + } + } +} diff --git a/database/factories/CustomFieldFactory.php b/database/factories/CustomFieldFactory.php index 44ab0707e0..30c41ce4e5 100644 --- a/database/factories/CustomFieldFactory.php +++ b/database/factories/CustomFieldFactory.php @@ -93,6 +93,42 @@ class CustomFieldFactory extends Factory }); } + public function encrypt() + { + return $this->state(function () { + return [ + 'field_encrypted' => '1', + ]; + }); + } + + public function alpha() + { + return $this->state(function () { + return [ + 'format' => 'alpha', + ]; + }); + } + + public function numeric() + { + return $this->state(function () { + return [ + 'format' => 'numeric', + ]; + }); + } + + public function email() + { + return $this->state(function () { + return [ + 'format' => 'email', + ]; + }); + } + public function testCheckbox() { return $this->state(function () { diff --git a/tests/Feature/Assets/Api/StoreAssetTest.php b/tests/Feature/Assets/Api/StoreAssetTest.php index 4629c79d35..a5531472a0 100644 --- a/tests/Feature/Assets/Api/StoreAssetTest.php +++ b/tests/Feature/Assets/Api/StoreAssetTest.php @@ -731,6 +731,65 @@ class StoreAssetTest extends TestCase $this->assertEquals('This is encrypted field', Crypt::decrypt($asset->{$field->db_column_name()})); } + public function test_encrypted_custom_field_validation_passes() + { + $this->markIncompleteIfMySQL('Custom Fields tests do not work on MySQL'); + + $status = Statuslabel::factory()->readyToDeploy()->create(); + $alphaField = CustomField::factory()->encrypt()->alpha()->create(); + $numericField = CustomField::factory()->encrypt()->numeric()->create(); + $emailField = CustomField::factory()->encrypt()->email()->create(); + $fields = [$alphaField, $numericField, $emailField]; + $superuser = User::factory()->superuser()->create(); + $assetData = Asset::factory()->hasMultipleCustomFields($fields)->make(); + + $response = $this->actingAsForApi($superuser) + ->postJson(route('api.assets.store'), [ + $alphaField->db_column_name() => 'Thisisencryptedfield', + $numericField->db_column_name() => '1234567890', + $emailField->db_column_name() => 'poop@poop.com', + 'model_id' => $assetData->model->id, + 'status_id' => $status->id, + 'asset_tag' => '1234', + ]) + ->assertStatusMessageIs('success') + ->assertOk() + ->json(); + + $asset = Asset::findOrFail($response['payload']['id']); + $this->assertEquals('Thisisencryptedfield', Crypt::decrypt($asset->{$alphaField->db_column_name()})); + $this->assertEquals('1234567890', Crypt::decrypt($asset->{$numericField->db_column_name()})); + $this->assertEquals('poop@poop.com', Crypt::decrypt($asset->{$emailField->db_column_name()})); + } + + public function test_encrypted_custom_field_validation_fails() + { + $this->markIncompleteIfMySQL('Custom Fields tests do not work on MySQL'); + + $status = Statuslabel::factory()->readyToDeploy()->create(); + $alphaField = CustomField::factory()->encrypt()->alpha()->create(); + $numericField = CustomField::factory()->encrypt()->numeric()->create(); + $emailField = CustomField::factory()->encrypt()->email()->create(); + $fields = [$alphaField, $numericField, $emailField]; + $superuser = User::factory()->superuser()->create(); + $assetData = Asset::factory()->hasMultipleCustomFields($fields)->make(); + $cleaned_name = trim(preg_replace('/_+|snipeit|\d+/', ' ', $alphaField->db_column_name())); + + $response = $this->actingAsForApi($superuser) + ->postJson(route('api.assets.store'), [ + $alphaField->db_column_name() => 'Thisisencryptedfield123', + 'model_id' => $assetData->model->id, + 'status_id' => $status->id, + 'asset_tag' => '1234', + ]) + ->dump() + ->assertStatusMessageIs('error') + ->assertJsonPath('messages.'.$alphaField->db_column_name(), [trans('validation.alpha', ['attribute' => $cleaned_name])]) + ->assertOk() + ->json(); + } + + public function testPermissionNeededToStoreEncryptedField() { // @todo: diff --git a/tests/Feature/Assets/Ui/BulkEditAssetsTest.php b/tests/Feature/Assets/Ui/BulkEditAssetsTest.php index 58f7a11598..36bb9346b0 100644 --- a/tests/Feature/Assets/Ui/BulkEditAssetsTest.php +++ b/tests/Feature/Assets/Ui/BulkEditAssetsTest.php @@ -29,7 +29,7 @@ class BulkEditAssetsTest extends TestCase ])->assertStatus(200); } - public function testStandardUserCannotAccessPage() + public function test_standard_user_cannot_access_page() { $user = User::factory()->create(); $assets = Asset::factory()->count(2)->create(); @@ -44,7 +44,7 @@ class BulkEditAssetsTest extends TestCase ])->assertStatus(403); } - public function testBulkEditAssetsAcceptsAllPossibleAttributes() + public function test_bulk_edit_assets_accepts_all_possible_attributes() { // sets up all needed models and attributes on the assets // this test does not deal with custom fields - will be dealt with in separate cases @@ -112,7 +112,7 @@ class BulkEditAssetsTest extends TestCase }); } - public function testBulkEditAssetsNullsOutFieldsIfSelected() + public function test_bulk_edit_assets_nulls_out_fields_if_selected() { // sets up all needed models and attributes on the assets // this test does not deal with custom fields - will be dealt with in separate cases @@ -165,7 +165,7 @@ class BulkEditAssetsTest extends TestCase }); } - public function testBulkEditAssetsAcceptsAndUpdatesUnencryptedCustomFields() + public function test_bulk_edit_assets_accepts_and_updates_unencrypted_custom_fields() { $this->markIncompleteIfMySQL('Custom Fields tests do not work on MySQL'); @@ -197,7 +197,7 @@ class BulkEditAssetsTest extends TestCase }); } - public function testBulkEditAssetsNullsCustomFieldsIfSelected() + public function test_bulk_edit_assets_nulls_custom_fields_if_selected() { $this->markIncompleteIfMySQL('Custom Fields tests do not work on MySQL'); @@ -238,7 +238,7 @@ class BulkEditAssetsTest extends TestCase }); } - public function testBulkEditAssetsAcceptsAndUpdatesEncryptedCustomFields() + public function test_bulk_edit_assets_accepts_and_updates_encrypted_custom_fields() { $this->markIncompleteIfMySQL('Custom Fields tests do not work on MySQL'); @@ -262,7 +262,7 @@ class BulkEditAssetsTest extends TestCase }); } - public function testBulkEditAssetsRequiresadminToUpdateEncryptedCustomFields() + public function test_bulk_edit_assets_requires_admin_to_update_encrypted_custom_fields() { $this->markIncompleteIfMySQL('Custom Fields tests do not work on mysql'); $edit_user = User::factory()->editAssets()->create();